Passwords are dying. Passkeys are shipping by default in every major browser. But JWT and sessions are not gone. They just have to be implemented correctly.
Here are 6 concrete authentication patterns that still matter in 2026, and how they change once passkeys enter the picture.
1. JWT in httpOnly Cookies Instead of localStorage
Most JWT examples online still store tokens in localStorage. That is a built-in XSS vulnerability.
Before (common but unsafe)
// login.ts
const { accessToken } = await fetch("/api/login").then(r => r.json());
localStorage.setItem("access_token", accessToken);
// later
fetch("/api/protected", {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`
}
});
Any script injection can read that token.
After (httpOnly cookies)
// route.ts
cookieStore.set("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 15,
path: "/"
});
// protected route
const token = request.cookies.get("access_token")?.value;
const payload = await verifyToken(token);
httpOnly removes the entire token exfiltration class via XSS. This single change eliminates one of the most common auth breaches.
2. Short-Lived Access + Rotating Refresh Tokens
A 30-day JWT is not authentication. It is a permanent credential.
Before (long-lived access token)
new SignJWT({ userId })
.setExpirationTime("30d")
.sign(secret);
If leaked, the attacker has 30 days.
After (15m access + rotation)
// access
new SignJWT({ userId })
.setExpirationTime("15m")
.sign(secret);
// refresh
new SignJWT({ userId, type: "refresh" })
.setExpirationTime("7d")
.sign(secret);
On refresh:
// invalidate old refresh token in DB
await db.update(refreshTokens)
.set({ revoked: true })
.where(eq(refreshTokens.id, oldTokenId));
// issue new refresh token
Rotation means stolen refresh tokens cannot be reused silently. This is mandatory for production JWT setups.
If you want a deeper breakdown of why AI-generated auth code often misses rotation and revocation entirely, read this analysis on web security risks in AI-generated JavaScript applications.
3. Immediate Revocation With Database Sessions
JWT cannot be revoked instantly without adding a database lookup. At that point, you are reimplementing sessions.
Before (JWT only)
const payload = await jwtVerify(token, secret);
If the user is fired, the token still works until expiry.
After (database-backed session)
const session = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId)
});
if (!session || session.expiresAt < Date.now()) {
return unauthorized();
}
To revoke access:
await db.delete(sessions).where(eq(sessions.userId, userId));
Delete the row. Access is gone immediately.
For internal dashboards, SaaS admin panels, or compliance-heavy apps, this pattern is simpler and safer than layered JWT revocation lists.
4. Proper OAuth With ID Token Validation
OAuth is authorization. OpenID Connect adds authentication. Many apps still mix these up.
Before (incorrect identity assumption)
// using access token as identity proof
const profile = await fetch("https://www.googleapis.com/oauth2/v1/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` }
});
Access token proves consent, not identity.
After (validate ID token)
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(idToken, googlePublicKey, {
issuer: "https://accounts.google.com",
audience: process.env.GOOGLE_CLIENT_ID
});
const userId = payload.sub;
The ID token is the cryptographic proof of identity. Without verifying issuer and audience, you are trusting unvalidated input.
5. Passkey Registration With Challenge Verification
Passkeys remove passwords, but the security comes from strict challenge validation.
Registration options
const options = await generateRegistrationOptions({
rpName: "YourApp",
rpID: "yourapp.com",
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred"
}
});
Store the challenge server-side:
await db.insert(challenges).values({
userId: user.id,
challenge: options.challenge,
expiresAt: Date.now() + 5 * 60 * 1000
});
Verification
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com"
});
The expectedOrigin and expectedRPID checks are what kill phishing. A fake domain cannot satisfy them.
This is the core shift in 2026 auth. Instead of hashing secrets, you verify cryptographic proofs bound to your domain.
6. Strict Separation of Authentication and Authorization
Changing from JWT to sessions or adding passkeys should not touch your permission system.
Authentication layer
// middleware.ts
const session = await validateSession(request);
if (!session) return redirect("/login");
request.headers.set("x-user-id", session.userId);
request.headers.set("x-user-role", session.role);
Authorization layer
type Role = "admin" | "editor" | "member";
const permissions: Record<Role, string[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
member: ["read"]
};
export function can(role: Role, action: string) {
return permissions[role]?.includes(action);
}
Usage:
if (!can(role, "delete")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
Auth answers who you are. Authorization answers what you can do. Keep them separate and migrations become trivial.
What Survives the Passkey Era
Passkeys eliminate credential stuffing and phishing. They do not eliminate:
- Session validation
- Token rotation
- Role-based authorization
- Revocation logic
- CSRF protection
- Rate limiting
If you are building a Next.js app today, the practical stack is:
- Database sessions
- OAuth providers
- Optional passkey support
- Strict authorization layer
- No tokens in localStorage
You do not need to debate JWT versus sessions endlessly. You need to implement whichever one you choose correctly.
Audit your current auth flow this week. Check where tokens are stored. Check expiry times. Check whether refresh tokens rotate. Check whether you validate ID tokens. Add passkeys as an option.
Most breaches do not happen because teams chose the wrong library. They happen because someone copied auth code and never understood what it was doing.
Top comments (0)