DEV Community

JSGuruJobs
JSGuruJobs

Posted on

6 JavaScript Auth Patterns That Survive the Passkey Era

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")}`
  }
});
Enter fullscreen mode Exit fullscreen mode

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: "/"
});
Enter fullscreen mode Exit fullscreen mode
// protected route
const token = request.cookies.get("access_token")?.value;
const payload = await verifyToken(token);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

On refresh:

// invalidate old refresh token in DB
await db.update(refreshTokens)
  .set({ revoked: true })
  .where(eq(refreshTokens.id, oldTokenId));

// issue new refresh token
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

To revoke access:

await db.delete(sessions).where(eq(sessions.userId, userId));
Enter fullscreen mode Exit fullscreen mode

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}` }
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
  }
});
Enter fullscreen mode Exit fullscreen mode

Store the challenge server-side:

await db.insert(challenges).values({
  userId: user.id,
  challenge: options.challenge,
  expiresAt: Date.now() + 5 * 60 * 1000
});
Enter fullscreen mode Exit fullscreen mode

Verification

const verification = await verifyRegistrationResponse({
  response,
  expectedChallenge,
  expectedOrigin: "https://yourapp.com",
  expectedRPID: "yourapp.com"
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Usage:

if (!can(role, "delete")) {
  return Response.json({ error: "Forbidden" }, { status: 403 });
}
Enter fullscreen mode Exit fullscreen mode

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)