Truly Passwordless
No password is ever derived, transmitted, or stored β not even a hash. A random Ed25519 key is generated on the device and encrypted locally with Argon2id.
Schnorr Proof of Knowledge on Ed25519. A random keypair lives on your device, encrypted by your PIN β the server stores only a public key, and nothing useful is ever sent over the wire.
ZKP Auth lets users authenticate without a password ever reaching the server β not even as a bcrypt hash.
Instead, the browser generates a random Ed25519 keypair on first registration, encrypts the private key with Argon2id + AES-256-GCM using a local PIN, and stores the encrypted blob in IndexedDB. The server stores only the 32-byte public key. On login, the PIN decrypts the key locally, a Schnorr proof is computed, and the key is immediately zeroed. No password database means no password database to breach.
| Package | Role | Install target |
|---|---|---|
@zkp-auth/core | Schnorr crypto primitives | Server (Node 20+) |
@zkp-auth/server | Express middleware + JWT | Server |
@zkp-auth/client | Browser SDK β keypair, storage, proof | Browser |
@zkp-auth/react | React hooks + context | Browser |
With traditional password authentication:
bcrypt(password) in the database.bcrypt(attempt) to stored hash.Attack surfaces: database breach exposes all hashes; offline cracking converts weak hashes to passwords; rainbow tables, timing attacks.
With ZKP Auth v0.2+:
Attack surfaces eliminated: no hashes to steal, no offline cracking possible, no password oracle, replay attacks are provably impossible (each proof binds to a fresh server-issued challenge).
When should I use it?
ZKP Auth is a good fit when you want strong security guarantees without building an OAuth/OIDC infrastructure. It is not a replacement for MFA or certificate-based auth, but it is a dramatic improvement over bcrypt-based password stores.
Current status
This library is pre-1.0 and has not been independently audited. See the Security Model page for details. Use it in production only after your own security review.
npm install @zkp-auth/server
# or
pnpm add @zkp-auth/servernpm install @zkp-auth/clientnpm install @zkp-auth/client @zkp-auth/reactGet ZKP authentication running in five minutes.
// server.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import {
zkpRegister,
zkpChallenge,
zkpVerify,
InMemoryChallengeStore,
RegistrationFailedError,
} from '@zkp-auth/server';
const app = express();
app.use(express.json());
app.use(cookieParser());
// In-memory store β swap for Redis/Postgres in production
const store = new InMemoryChallengeStore();
// In-memory user "database" β swap for your real DB
const users = new Map<string, Uint8Array>();
// Route 1 β register: stores the user's randomly generated public key
app.post(
'/auth/register',
zkpRegister({
getPublicKey: async (userId) => users.get(userId) ?? null,
savePublicKey: async (userId, publicKey) => {
if (users.has(userId)) throw new RegistrationFailedError();
users.set(userId, publicKey);
},
}),
);
// Route 2 β challenge: issue a fresh one-time nonce
app.post('/auth/challenge', zkpChallenge({ store }));
// Route 3 β verify: check the proof, issue a JWT
app.post(
'/auth/verify',
zkpVerify({
getPublicKey: async (userId) => users.get(userId) ?? null,
store,
jwtSecret: process.env.JWT_SECRET!,
}),
(req, res) => {
res.cookie('auth', res.locals.zkpToken, {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
});
res.json({ ok: true });
},
);
app.listen(3001, () => console.log('ZKP auth server on :3001'));// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ZKPProvider } from '@zkp-auth/react';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZKPProvider options={{ baseUrl: 'http://localhost:3001' }}>
<App />
</ZKPProvider>
</StrictMode>,
);// AuthForm.tsx
import { useZKPAuth } from '@zkp-auth/react';
export function AuthForm() {
const { register, login, hasLocalKey, logout, isAuthenticated, loading, error, user } =
useZKPAuth();
if (isAuthenticated) {
return (
<div>
<p>Welcome, {user?.userId}!</p>
<button onClick={logout}>Log out</button>
</div>
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const username = form.get('username') as string;
const pin = form.get('pin') as string;
// Automatically routes to register or login based on device state
const exists = await hasLocalKey(username);
exists ? await login(username, pin) : await register(username, pin);
}
return (
<>
{error && <p style={{ color: 'red' }}>{error.message}</p>}
<form onSubmit={handleSubmit}>
<h2>Sign in</h2>
<input name="username" placeholder="Username" required />
<input
name="pin"
type="password"
placeholder="PIN (stays on this device β never sent)"
/>
<button type="submit" disabled={loading}>
{loading ? 'Authenticatingβ¦' : 'Continue'}
</button>
</form>
</>
);
}That's it. No password is ever transmitted or stored on the server.