
Ask a room of developers whether to use sessions, JWTs or passkeys, and you will get a confident answer and an argument. The argument exists because the question is malformed. These three things are not flavors of the same product. They answer different questions.
Authentication has two jobs:
Proving identity. How does a user show they are who they claim to be? A password, a passkey or a social login are all ways to do this.
Remembering the proof. After the user logs in once, how does your app know the next request is still theirs? A server-side session or a JWT are ways to do this.
Passkeys belong to the first job. Sessions and JWTs belong to the second. So the real decision is two decisions, and most of the confusion disappears once you separate them.
flowchart LR
A["1. Prove identity: passkey, password or social login"] --> B["2. Issue proof of login: session ID or JWT"]
B --> C["3. Client sends it with every request"]
C --> D["4. Server verifies it and serves the request"]This guide covers what each approach does, where it fails, and how to combine them. It ends with a decision framework you can apply in a few minutes.
The short version
Dimension | Server-side sessions | JWTs | Passkeys |
|---|---|---|---|
Question it answers | How do we remember a login? | How do we remember a login, verifiably, across services? | How does the user prove who they are? |
Where the state lives | Your server store, with an ID in a cookie | Inside the signed token | Public key on your server, private key on the user’s device |
Ending access early | Instant: delete the record | Hard until expiry, unless you add a denylist | Not their job: you still end the session |
Works across services | Needs a shared store or gateway | Yes, via public-key verification | Per site, bound to your domain |
Main risk | CSRF, session fixation, store availability | Long-lived tokens, token theft, validation mistakes | Account recovery and rollout |
Best for | First-party web apps | APIs, microservices, federated login | The primary way users log in |
If you remember only one thing: for most web products, the best setup is passkey login feeding a server-side session. JWTs earn their place when someone who cannot call your session store has to verify the login.
Server-side sessions
A session is a record your server keeps. After login you create the record, store it in Redis or a database, and give the browser a random, meaningless ID in a cookie. On every request the browser sends the cookie and you look the record up.
// After the user proves who they are
const sessionId = crypto.randomBytes(32).toString("base64url");
await redis.set(`session:${sessionId}`, JSON.stringify({ userId: user.id }), { EX: 60 * 60 * 24 * 7 });
res.cookie("__Host-sid", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// On every later request
const raw = await redis.get(`session:${req.cookies["__Host-sid"]}`);
if (!raw) return res.status(401).end();
req.user = JSON.parse(raw);The cookie carries no information. All the knowledge lives on your server, and that is the whole point.
Where sessions win
Instant revocation. Delete the record and the user is logged out everywhere, immediately. That matters for stolen devices, password changes and bans.
Small and well understood. Every major web framework ships session support, and the failure modes are documented.
Server-controlled data. Roles and permissions can change mid-session, and the next request sees the change.
Safer in the browser by default. An HttpOnly cookie cannot be read by JavaScript, so a cross-site scripting bug cannot simply copy the credential out.
Where sessions hurt
A lookup on every request. Usually a fast in-memory read, but it is a dependency your app must keep available.
Shared state across servers. Multiple instances need a shared store such as Redis, not local memory.
CSRF. Browsers attach cookies automatically, so cookie-based apps need defenses: SameSite cookies, Origin checks or anti-CSRF tokens.
Awkward across origins and native apps. Cookies behave best when the client and the API share a site.
JWTs
A JSON Web Token is a signed, self-contained claim: a header, a payload with claims such as the user ID, issuer, audience and expiry, and a signature. Anyone holding the right key can verify it without calling you.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256"],
});Two things people forget. The payload is only encoded, not encrypted, so anyone who holds the token can read it. And a valid signature proves the token was issued by you, not that it is still wanted.
Where JWTs win
Verification without a database call. Microservices, edge functions and third-party APIs can verify a token using your public keys.
A standard ecosystem. OAuth 2.0 and OpenID Connect are built around them, so federated login and API access tokens come naturally.
Works across domains and clients. Mobile apps, single-page apps on other origins and service-to-service calls all fit.
Where JWTs hurt
Revocation is hard. A JWT stays valid until it expires. Killing one early means keeping a denylist, which is state, which is the thing you adopted JWTs to avoid.
Stale claims. Roles inside the token do not update until it expires.
Heavier requests. The token travels on every request and grows with every claim you add.
Easy to validate wrongly. Accepting the wrong algorithm, skipping issuer or audience checks and ignoring expiry have all caused real vulnerabilities. RFC 8725 collects the best current practices.
A storage dilemma. In localStorage, any XSS bug can steal the token. In an HttpOnly cookie, you have rebuilt a session cookie with a heavier payload.
The usual fix is short lifetimes. Issue access tokens that last minutes, and pair them with a refresh token that is rotated on every use. OAuth’s current security guidance, RFC 9700 (published in January 2025), says refresh tokens for public clients must be either sender-constrained or rotated.
The common mistake is using JWTs as the session for a single web app. You inherit every drawback above and use none of the benefits.
Passkeys
Passkeys are the friendly name for WebAuthn credentials, part of the FIDO standards. When a user registers, their device generates a key pair for your site. Your server stores the public key. The private key stays on the user’s device or in their password manager and is never sent anywhere.
To log in, your server sends a random challenge, and the device signs it after the user confirms with a fingerprint, a face scan or a device PIN.
sequenceDiagram
participant D as User device
participant B as Browser
participant S as Your server
S->>B: Random challenge
B->>D: Ask to sign the challenge for this origin
D->>D: Confirm with fingerprint, face or PIN
D->>B: Signature from the private key
B->>S: Signed response
S->>S: Verify with the stored public key
S->>B: Create a session (cookie or token)Where passkeys win
Phishing resistant. The credential is bound to your domain. A lookalike site cannot obtain a valid signature for yours, because the browser will not offer the passkey there.
Nothing to leak. A database breach exposes public keys, which are useless to an attacker. There are no passwords to reuse, stuff or guess.
A better experience. Biometric confirmation is faster than typing, and browsers can offer passkeys straight from the username field’s autofill.
Strong assurance. NIST’s revised digital identity guidelines (SP 800-63B-4, final in 2025) recognize syncable passkeys at assurance level AAL2, and hardware-backed, device-bound passkeys can reach AAL3.
Where passkeys hurt
Account recovery is the hard part. A user who loses every device needs another way back in. Design that path before launch, and remember it is only as strong as your weakest recovery option.
They are not a session. After the signature checks out, you still have to create a session or issue a token. The last line of the diagram is the part passkeys do not do for you.
Ecosystem friction. Synced passkeys live in a platform account (Apple, Google, Microsoft) or a password manager, and moving between ecosystems can be clunky. Cross-device sign-in works, but users need guidance.
Tied to your domain. Passkeys are bound to your relying party ID, so a domain change needs planning.
Rollout takes time. Most products run passkeys alongside other login methods for a while.
Choosing: a two-step decision
Decide the two questions in order.
Step 1: how will users prove who they are? Offer passkeys as the primary option. Keep a fallback you trust (an email link, a social login, or a password with breach checks) and a tested recovery path.
Step 2: how will your system remember the proof? That depends on who needs to verify it.
flowchart TD
A["Who needs to verify the login?"] -->|"Only my own web backend"| S["Server-side session in an HttpOnly cookie"]
A -->|"A mobile app or SPA on another origin"| M["Short-lived access token plus rotating refresh token, or a backend-for-frontend with cookie sessions"]
A -->|"Other services or third-party APIs"| J["Signed JWT access tokens via OAuth 2.0 or OIDC"]The rules of thumb behind it:
One web app you own end to end? Use server-side sessions.
Services or third parties that must verify without calling you? Use JWTs with short lifetimes.
A mobile or cross-origin client? Use short-lived tokens with refresh rotation, or put a thin backend in front that holds the session and speaks cookies to the browser.
A regulated environment? Work out which assurance level you owe your users. Device-bound passkeys can reach higher levels than synced ones.
A combined architecture that works
The approaches stack well when each handles its own layer:
The user signs in with a passkey, with a password or social login as the fallback.
Your auth service verifies it and creates a server-side session, delivered as a cookie.
Your web app and API use that cookie for everyday requests.
When your backend calls internal services or partner APIs on the user’s behalf, it mints a short-lived JWT scoped to that single call.
Notice where each one sits. Sessions guard the front door, JWTs cross the trust boundaries inside and outside your system, and passkeys are how people get in.
Three mistakes worth avoiding
Choosing JWTs because they “scale.” A shared session store scales well, and the stateless advantage disappears the moment you need revocation.
Treating passkeys as the whole system. You still need sessions, a recovery flow and a way to retire a lost device’s credential. Let users register more than one passkey.
Skipping session hygiene. Issue a fresh session ID at login to prevent fixation, set both idle and absolute timeouts, and defend against CSRF.
Build the policy, borrow the cryptography
WebAuthn verification and JWT validation are exactly the places where small mistakes become vulnerabilities. Use maintained libraries or an identity provider for those parts. What belongs to you is the policy: how long sessions last, what recovery looks like, and who is allowed to do what.
The verdict
Sessions are the safest default for any web app you own end to end.
JWTs are for crossing trust boundaries, not for replacing sessions inside one app.
Passkeys are the best way to log in, and they feed one of the other two.
Separate the two questions and the choice gets small. Pick how people prove who they are, then pick how your system remembers it, and stop comparing things that were never competitors.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!