Passwordless Authentication with Client-Side Key Derivation
Building secure passwordless systems using client-side key derivation and modern cryptographic primitives like PBKDF2 and Argon2.
Traditional password-based authentication has dominated the web for decades, but it introduces security burden on both users and systems. Passwordless authentication eliminates this friction by replacing passwords with cryptographic proofs. Client-side key derivation is a powerful technique that enables users to prove possession of a secret without ever transmitting it to the server.
The Problem with Passwords
Password-based authentication creates a security paradox: users must remember strong, unique passwords for every service, yet remembering them encourages weak reuse and poor practices. Servers bear the burden of securely storing password hashes and managing breach risk. And users face the constant threat of phishing attacks that trick them into surrendering their credentials.
Passwordless authentication solves this by shifting from "something you know" to "something you have" or "something you are." But how do we make this work securely and conveniently?
Client-Side Key Derivation: The Bridge
Client-side key derivation allows your application to transform a user-supplied secret (or a recovery phrase) into a cryptographic key in the browser, never transmitting the original secret to the server. The server sees only the derived key or a cryptographic proof of its possession.
Here's the flow:
- User provides input — a passphrase, biometric unlock, or recovery phrase
- Client derives a key — using a key derivation function (KDF) like PBKDF2 or Argon2
- Client signs or encrypts a challenge — proving knowledge of the key
- Server verifies the proof — without ever seeing the original input
The beauty here is asymmetry: the user's secret stays client-side, but the server gains cryptographic certainty of identity.
PBKDF2: The Workhorse
PBKDF2 (Password-Based Key Derivation Function 2) has been the standard for two decades. It's simple, audited extensively, and available in every language and browser.
import { pbkdf2 } from 'crypto';
async function deriveKey(password, salt, iterations = 600000) {
return pbkdf2('sha256', password, salt, 32, (err, derivedKey) => {
if (err) throw err;
return derivedKey;
});
}
In the browser with SubtleCrypto:
async function deriveKeyWeb(password, salt) {
const encoder = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 600000,
hash: 'SHA-256'
},
baseKey,
{ name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify']
);
}
Key parameters:
- Iterations: Tune this to your hardware. Modern guidance suggests
100,000to1,000,000— higher makes cracking more expensive. - Salt: A random value unique to each user. Store this alongside the user record — it doesn't need to be secret.
- Hash function: SHA-256 is standard; SHA-512 is also acceptable.
Argon2: The Modern Choice
Argon2 won the Password Hashing Competition (2015) for good reasons: it resists GPU and ASIC attacks by requiring significant memory in addition to computation. This makes brute-force attacks orders of magnitude more expensive.
Unfortunately, SubtleCrypto doesn't support Argon2 natively (yet). Use libsodium.js or tweetnacl.js:
import sodium from 'libsodium.js';
async function deriveKeyArgon2(password, salt) {
await sodium.ready;
return sodium.crypto_pwhash(
32, // output length in bytes
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
sodium.crypto_pwhash_ALG_DEFAULT
);
}
Argon2 parameters:
- opslimit: CPU cost.
OPSLIMIT_MODERATEis a good balance for user-facing flows. - memlimit: RAM cost in bytes.
MEMLIMIT_MODERATErequires ~64 MB.
The memory requirement is critical: it prevents attackers from parallelizing attacks on commodity hardware.
Building a Passwordless Flow
Here's a complete example using client-side key derivation:
// Client: Register
async function register(email, passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKeyWeb(passphrase, salt);
// Export the derived key as raw bytes
const keyBytes = await crypto.subtle.exportKey('raw', derivedKey);
// Sign a challenge with the derived key
const challenge = new TextEncoder().encode(email + Date.now());
const signature = await crypto.subtle.sign(
'HMAC',
derivedKey,
challenge
);
// Send to server
await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
salt: Array.from(new Uint8Array(salt)),
publicKey: Array.from(new Uint8Array(keyBytes)), // For verification
signature: Array.from(new Uint8Array(signature))
})
});
}
// Server: Verify registration
app.post('/register', (req, res) => {
const { email, salt, publicKey, signature } = req.body;
// Store the public key and salt for this user
db.users.insert({
email,
salt: Buffer.from(salt),
publicKey: Buffer.from(publicKey)
});
res.json({ success: true });
});
// Client: Authenticate
async function authenticate(email, passphrase) {
// Fetch salt from server
const { salt } = await fetch(`/salt/${email}`).then(r => r.json());
const saltBytes = new Uint8Array(salt);
// Re-derive the key from passphrase + salt
const derivedKey = await deriveKeyWeb(passphrase, saltBytes);
// Sign a fresh challenge
const challenge = new TextEncoder().encode(email + Date.now());
const signature = await crypto.subtle.sign('HMAC', derivedKey, challenge);
// Authenticate
const response = await fetch('/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
challenge: Array.from(new Uint8Array(challenge)),
signature: Array.from(new Uint8Array(signature))
})
});
return response.json();
}
// Server: Verify authentication
app.post('/authenticate', async (req, res) => {
const { email, challenge, signature } = req.body;
const user = db.users.findOne({ email });
const publicKey = await crypto.subtle.importKey(
'raw',
Buffer.from(user.publicKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const isValid = await crypto.subtle.verify(
'HMAC',
publicKey,
Buffer.from(signature),
Buffer.from(challenge)
);
if (isValid) {
// Issue session token
res.json({ sessionToken: generateToken() });
} else {
res.status(401).json({ error: 'Authentication failed' });
}
});
Security Considerations
-
Salt uniqueness: Use a cryptographically random salt for each user. Never reuse.
-
Iteration counts: Higher is more secure but slower. Benchmark on your target devices; aim for
100–500milliseconds on modern phones. -
Timing attacks: Use constant-time comparison for signatures. Node.js provides
crypto.timingSafeEqual(). -
Transmission: Always use HTTPS. The derived key is cryptographic material — treat it with the same care as a private key.
-
Key stretching: If users derive keys from biometrics, add a stretching step like Argon2 before use.
-
Recovery codes: Passwordless systems need a fallback. Generate and encrypt recovery codes server-side, store them encrypted client-side.
Performance Trade-offs
- PBKDF2: Fast for the user (
100–200ms), but also fast for attackers. Use600,000+iterations. - Argon2: Slower for users (
500–1000ms), but exponentially slower for attackers due to memory cost. Recommended.
For user registration and periodic re-authentication (e.g., sensitive operations), Argon2's slower speed is acceptable. For login on every request, cache the derived key in memory or IndexedDB (encrypted) to avoid re-deriving on every access.
The Future of Passwordless
Client-side key derivation is one pillar of passwordless auth. Combine it with:
- WebAuthn/FIDO2: Hardware security keys and platform biometrics
- Decentralized identity: Self-sovereign credentials and verifiable claims
- Zero-knowledge proofs: Prove attributes without revealing underlying data
The transition from passwords to cryptographic proofs is underway. Starting with client-side key derivation gives your application security foundations that scale to more advanced schemes.
Further reading: