Ask anything about this article
Hi! I've read this article.
What would you like to know?
@farhan
The web security community has been buzzing for months about the FIDO Alliance's push for passkeys (WebAuthn + credential management) as a password-less replacement. At the same time, the JWT ecosystem is choking on token bloat, refresh-token misuse, and endless "stateless" debates. In this hot take I argue that passkeys are not just a nice-to-have UX upgrade – they are the decisive factor that will make JWT-based APIs obsolete for most consumer apps. If you keep betting on JWT for everything, you are building on a sinking ship.
When OAuth 2.0 and OpenID Connect popularized JWT in 2015, developers loved the idea of a self-contained token that could travel across services without a database lookup. The pattern looks like this:
js
// Issue a JWT after user logs in
const jwt = sign({ sub: user.id, role: user.role }, "mySecret", { expiresIn: "15m" });
The token is then sent on every request:
js
fetch("/api/profile", {
headers: { Authorization: Bearer ${jwt} }
});
Pros:
Cons:
These pain points are why the community is now looking for a better answer.
Passkeys replace passwords with a cryptographic key pair stored in the platform authenticator (Phone, Windows Hello, Touch ID). The flow is simple:
The same WebAuthn spec that powers Apple’s "Sign in with Apple" and Google’s "Passkey" works across browsers. Example using the @simplewebauthn/server library:
js
// Step 1: Generate registration options
const opts = await generateRegistrationOptions({
rpName: "MyApp",
userID: user.id,
userName: user.email,
});
await saveChallenge(user.id, opts.challenge);
js
// Step 2: Verify the attestation response
const verification = await verifyRegistrationResponse({
credential: req.body,
expectedChallenge: await getChallenge(user.id),
expectedOrigin: "https://myapp.com",
expectedRPID: "myapp.com",
});
if (verification.verified) {
await storePublicKey(user.id, verification.registrationInfo.credentialPublicKey);
}
No passwords, no OTPs, no "reset my password" tickets. The private key never leaves the device, and the server only stores a public key – a one-way artifact that cannot be used to impersonate the user elsewhere.
Contrast this with a typical JWT + refresh token pattern:
js
// Refresh token endpoint
app.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
const payload = verify(refreshToken, "refreshSecret");
// Issue new access token
const newJwt = sign({ sub: payload.sub }, "accessSecret", { expiresIn: "15m" });
res.json({ token: newJwt });
});
If the refresh token is stolen (common via XSS or insecure storage), an attacker can generate fresh access tokens forever until the user manually revokes it. The mitigation is a central revocation list, which re-introduces statefulness and defeats the original purpose of JWT.
I’m not saying JWT is dead – it’s still great for service-to-service authentication where you need short-lived, signed assertions. But for end-user sessions in consumer apps, passkeys + short-lived opaque session IDs are a cleaner, more secure stack.
Proposed modern stack:
session_id) that references a server-side session row with minimal data (user_id, expiration).@simplewebauthn/browser for the front-end.expiresIn on JWTs, replace calls with the new endpoint that returns a new session cookie after silent re-auth.These moves are not marketing fluff; they reflect a shift in the threat model. When the biggest browsers and OS vendors make passkeys the default, clinging to JWT for UI login is an anti-pattern.
If you disagree, feel free to argue that "stateless is king". Just remember that "stateless" has a cost – it’s the cost of every user's password being stored somewhere, and the cost of every stolen refresh token. Passkeys turn that cost into zero, and the industry is already moving in that direction. Jump on the train now, or spend the next year firefighting token-related breaches.