Authentication is where I see the most confident wrong answers. "Just use JWTs" ships a token in localStorage that any XSS can steal. "Just use OAuth" implements a login system with a protocol designed for delegation. Meanwhile passkeys, the actual solution to passwords, are treated as exotic. This guide lays out how the pieces fit in 2026, with the code I use for client applications.
Three different jobs
| Job | Question it answers | Tool |
|---|---|---|
| Authentication | Who is this? | Passkeys (WebAuthn), magic links, passwords as legacy |
| Delegation | May this app act for this user at another service? | OAuth 2.1 + PKCE (and OIDC for identity on top) |
| Session / token | How do later requests prove it is still them? | Server sessions (cookies) or JWT access + refresh tokens |
Most confusion is using the tool from one row to do the job in another.
Passkeys: how login should work now
A passkey is a public/private key pair. The private key never leaves the user's device (or their password manager's encrypted sync). To log in, your server sends a random challenge; the device signs it with the private key after the user unlocks with Face ID, fingerprint or PIN; you verify the signature with the stored public key. There is nothing to phish, nothing to reuse across sites, and nothing in your database worth stealing.
The @simplewebauthn packages handle the ceremony details. This is the server side in Express (the client side is two function calls: startRegistration() and startAuthentication()):
// auth/passkeys.ts
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const rpName = 'Shawab.Space App';
const rpID = 'app.example.com'; // your domain, no scheme/port
const origin = `https://${rpID}`;
// 1. Registration: options
export async function registrationOptions(user: User) {
const existing = await repo.credentialsForUser(user.id);
const options = await generateRegistrationOptions({
rpName, rpID,
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: 'none',
excludeCredentials: existing.map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
});
await repo.saveChallenge(user.id, options.challenge, 'register'); // short TTL (5 min)
return options;
}
// 2. Registration: verify + store the public key
export async function registrationVerify(user: User, response: unknown) {
const expectedChallenge = await repo.takeChallenge(user.id, 'register'); // single use
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: response as any, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID,
});
if (!verified || !registrationInfo) throw new AppError(400, 'Passkey registration failed');
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
await repo.saveCredential({
id: credential.id, userId: user.id, publicKey: credential.publicKey, counter: credential.counter,
transports: credential.transports ?? [], deviceType: credentialDeviceType, backedUp: credentialBackedUp,
});
}
// 3. Login: options (usernameless: let the device pick a discoverable credential)
export async function authenticationOptions(sessionId: string) {
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
await repo.saveChallenge(sessionId, options.challenge, 'login');
return options;
}
// 4. Login: verify and create a session
export async function authenticationVerify(sessionId: string, response: any) {
const expectedChallenge = await repo.takeChallenge(sessionId, 'login');
const cred = await repo.credentialById(response.id);
if (!cred) throw new AppError(401, 'Unknown passkey');
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: cred.id, publicKey: cred.publicKey, counter: cred.counter, transports: cred.transports },
});
if (!verified) throw new AppError(401, 'Passkey verification failed');
await repo.updateCounter(cred.id, authenticationInfo.newCounter); // clone detection
return createSession(cred.userId);
}
Always keep a fallback: an email magic link for users on a device without their passkey. Passwords, if you keep them at all, are the third option, hashed with Argon2id. On the client SaaS where I rolled this out, 61% of active users had added a passkey within three months, and password-reset tickets dropped by about half.
OAuth 2.1: delegation done right
OAuth 2.1 is the cleanup of 2.0: PKCE is mandatory for every client, the implicit and password grants are gone, and refresh tokens for public clients must rotate. Use it for two things: social login (via OpenID Connect, which adds an id_token with identity claims) and letting third-party apps access your API on behalf of a user.
// "Sign in with Google" via OIDC + PKCE, using the `arctic` library (no vendor SDK bloat)
import { Google, generateState, generateCodeVerifier } from 'arctic';
const google = new Google(process.env.GOOGLE_CLIENT_ID!, process.env.GOOGLE_CLIENT_SECRET!, 'https://app.example.com/auth/google/callback');
app.get('/auth/google', async (req, res) => {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = google.createAuthorizationURL(state, codeVerifier, ['openid', 'email', 'profile']);
res.cookie('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600_000 });
res.cookie('oauth_verifier', codeVerifier, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600_000 });
res.redirect(url.toString());
});
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.cookies.oauth_state) return res.status(400).send('Bad state'); // CSRF protection
const tokens = await google.validateAuthorizationCode(String(code), req.cookies.oauth_verifier); // PKCE proof
const claims = decodeIdToken(tokens.idToken()) as { sub: string; email: string; email_verified: boolean; name: string };
if (!claims.email_verified) return res.status(403).send('Unverified email');
const user = await repo.upsertUserFromOidc({ provider: 'google', subject: claims.sub, email: claims.email, name: claims.name });
await createSession(user.id, res);
res.redirect('/dashboard');
});
Two rules that prevent real account-takeover bugs: match accounts on the provider's sub, not on email alone, and never auto-link an OIDC login to an existing password account without proof the user owns it.
Sessions vs JWTs: the honest comparison
For a first-party web app, my default is a server-side session: a random 256-bit ID in an HttpOnly; Secure; SameSite=Lax cookie, mapped to user data in Redis or Postgres. Revocation is a DELETE. XSS cannot read the cookie. There is no expiry maths, no key rotation, no "the token says they are still an admin" problem.
JWTs make sense when several services need to verify identity without calling a central store on every request, or when mobile and third-party clients cannot use cookies. When you do use them, do it fully:
// auth/tokens.ts -- short access token + rotating refresh token with reuse detection
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
const ACCESS_TTL = '10m';
const REFRESH_TTL_S = 30 * 24 * 3600;
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, 'EdDSA');
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, 'EdDSA');
export async function issueAccessToken(user: { id: string; role: string }) {
return new SignJWT({ role: user.role })
.setProtectedHeader({ alg: 'EdDSA', kid: 'k2026-08' }) // kid enables zero-downtime key rotation
.setSubject(user.id).setIssuer('https://app.example.com').setAudience('api')
.setIssuedAt().setExpirationTime(ACCESS_TTL)
.sign(privateKey);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, { issuer: 'https://app.example.com', audience: 'api' });
return payload; // throws on expiry / bad signature; NEVER accept alg:none or HS256 from a public key setup
}
// Refresh tokens are opaque, stored hashed, in a "family". Reusing an old one revokes the whole family.
export async function rotateRefreshToken(presented: string) {
const row = await repo.refreshTokenByHash(sha256(presented));
if (!row) throw new AppError(401, 'Invalid refresh token');
if (row.usedAt) { // reuse = theft signal
await repo.revokeFamily(row.familyId);
throw new AppError(401, 'Refresh token reuse detected; please sign in again');
}
await repo.markUsed(row.id);
const next = randomToken();
await repo.insertRefreshToken({ familyId: row.familyId, userId: row.userId, hash: sha256(next), expiresAt: now() + REFRESH_TTL_S });
return { refreshToken: next, accessToken: await issueAccessToken(await repo.user(row.userId)) };
}
Store the refresh token in an HttpOnly cookie scoped to the /auth/refresh path for web clients, and in secure storage (Keychain/Keystore) on mobile. The access token can live in memory only; if it is in localStorage, an XSS on any page is a full account compromise.
Hardening checklist for every auth surface
- Rate limit login, registration, magic-link, refresh and reset endpoints per IP and per account. The token bucket limiter from my system design post fits here.
- Constant-time comparisons for tokens and codes (
timingSafeEqual), and identical responses for "no such user" and "wrong password". - Single-use, short-lived challenges and reset tokens, stored hashed, deleted on use.
- Session fixation: regenerate the session ID on every privilege change (login, role change, 2FA completion).
- Audit log every auth event with user, IP, user-agent and request ID. This is what lets you answer "was my account accessed?" honestly.
- Security headers: a strict CSP (mitigates XSS, which is the real threat to any token),
Strict-Transport-Security,Referrer-Policy. - WordPress specifically: application passwords for API access, no XML-RPC, 2FA plugin for admins, and put wp-login behind an allow-list or Cloudflare Access on client sites. Details in the lock-down section of my headless WordPress guide.
Offer passkeys with a magic-link fallback, keep first-party sessions in HttpOnly cookies, use OAuth only for delegation, and if you must use JWTs, make them short-lived and pair them with rotating refresh tokens. That combination closes the vulnerabilities behind most of the breaches you read about, and it is less code than the insecure version.
Auth is one of the few areas where I recommend a well-maintained library or managed provider over hand-rolling, and I still think every developer should build it once to understand the trade-offs. If you are choosing between building and buying for a client project, I can help you scope it.