Software / Security

Sessions vs JWT vs OAuth vs Passkeys: Authentication Methods Compared

/15 min read

Sessions, JWTs, OAuth 2.0, and passkeys answer four different questions: who is this user (sessions), how do I verify a claim without a database lookup (JWT), how does a third party act on a user's behalf (OAuth), and how do I prove identity without a shared secret at all (passkeys). Most teams choose one because a tutorial used it; the right choice depends on whether your app is first-party, whether it spans services, and whether your threat model includes phishing.

Sessions: Server-Side State, Still the Default

A session is a server-issued identifier — typically a random 128-bit value — stored in an httpOnly cookie and mapped to user state on the server. Every request carries the cookie; the server resolves it with a lookup in Redis, Memcached, or the primary database. The client never holds anything meaningful, so the entire trust boundary sits where you can audit it.

Three properties make sessions the safest default for first-party web apps: revocation is instant, the cookie is unreadable by JavaScript, and state lives in one place (“log out all devices” is a one-line query). The cost is a lookup per request — cacheable above a 95% hit rate — plus CSRF protection: browsers' SameSite covers most of it, and state-changing endpoints should still verify an origin header or double-submit token.

// Hardened session cookie — Node / Next.js style
res.setHeader(
  "Set-Cookie",
  serialize("sid", sessionId, {
    httpOnly: true,                 // JS cannot read it: blocks XSS token theft
    secure: true,                   // HTTPS only
    sameSite: "lax",                // sent on top-level navigation, not on
                                    // cross-site POSTs: kills classic CSRF
    path: "/",
    maxAge: 60 * 60 * 24 * 7,       // 7 days
    priority: "high",
  })
);

// Use SameSite=Strict for admin-only or banking surfaces.
// SameSite=None is a last resort: it requires Secure and
// only fits genuine cross-site redirect flows.

Sessions struggle in exactly two places: pure client-side SPAs on a separate domain from the API (cookie juggling gets awkward), and distributed services that would each need to call back to a session store. Both problems are real, and both are why JWTs became popular.

JWT: Stateless Verification and Its Two Problems

A JSON Web Token is three base64url segments —header.payload.signature — signed with a key the verifier already has. Verification is local: signature, exp, aud. No network call, no shared store — at 200 requests per second across 12 services, avoiding a central lookup is worth real money. That is the honest case for JWTs.

Both problems are structural. First, the rotation problem: rotate your signing key and every unexpired token signed with the old key is either rejected (users logged out) or still accepted (two trusted keys in the wild). Second, the revocation problem: a signed token is valid until it expires. There is no “revoke this” button unless you add a denylist — reintroducing server-side state, minus the convenience.

The fix is to stop using JWTs as session tokens. A JWT belongs as a short-lived access token — 5 to 15 minutes — fronted by a rotating refresh token stored server-side or in an httpOnly cookie. Refresh rotation with reuse detection restores instant revocation: when an old refresh token is replayed after being rotated, the whole family is burned and the user re-authenticates. That is the design OAuth 2.0 security guidance recommends and most identity providers ship today.

One hard rule: allowlist algorithms server-side and accept only asymmetric ones (RS256, ES256, EdDSA). The alg:noneclass of bugs and the “HS256 verified with the public key” confusion are two decades of CVEs compressed into one sentence.

OAuth 2.0 and OIDC: Delegated Authorization

OAuth 2.0 is not an authentication protocol. It answers “may this application act on my calendar?”, never “who am I?” The protocol grants a scoped, time-limited access token to a client on the user's behalf, with the user approving at the authorization server. Reading a user's identity out of an access token's claims is undefined behavior, not a feature.

OpenID Connect (OIDC) is the thin layer that makes OAuth authenticate: an id_token (a JWT asserting identity), a userinfo endpoint, and discovery via /.well-known/openid-configuration. Every “Sign in with Google” button is OIDC on top of OAuth 2.0. Delegating identity to Okta, Auth0, Microsoft Entra ID, or Keycloak buys you MFA, password policy, a user directory, and audit logs you did not have to build.

Public clients — SPAs, mobile apps, CLI tools — must use PKCE (Proof Key for Code Exchange, RFC 7636): the code issued at redirect is bound to the client that requested it, so an intercepted code is useless without the verifier. PKCE is mandatory in OAuth 2.1, which also removes the implicit flow entirely.

Browser / Native client                          Authorization server
  |                                                     |
  | 1. code_verifier  = random(43..128 chars)           |
  |    code_challenge = BASE64URL(SHA256(code_verifier))|
  |                                                     |
  | -- GET /authorize?response_type=code                |
  |      &client_id=...&redirect_uri=...                |
  |      &code_challenge=...&code_challenge_method=S256 ->|
  |                                                     |
  |      ... user authenticates and consents ...        |
  |                                                     |
  | <-- 302 redirect: /cb?code=Splxl... ---------------- |
  |                                                     |
  | -- POST /token  grant_type=authorization_code       |
  |      code=...  code_verifier=...  client_id=... ---> |
  |                                                     |
  |      server: SHA256(code_verifier) == challenge ?   |
  |      yes -> issue access_token (+ id_token)         |
  |                                                     |
  | <-- { access_token, expires_in, refresh_token } ---- |

Confidential clients (your own backend) keep the client secret and may omit PKCE, though adding it costs nothing. Public clients must never embed a secret — there is no such thing as a secret inside an app binary or a bundle of JavaScript.

Passkeys and WebAuthn: Phishing-Resistant by Construction

Passkeys are FIDO2 credentials: a public/private key pair generated by the user's device and registered with your site. The private key never leaves the authenticator — a platform biometric, a hardware key, or a synced passkey in iCloud Keychain, Google Password Manager, or 1Password. Authentication proves possession of the private key: no shared secret exists to steal, reuse, or phish.

Registration calls navigator.credentials.create() with a server challenge and stores the returned public key bound to your origin. Login calls navigator.credentials.get(), and the server verifies the signature against a server-issued challenge. Because the credential is cryptographically scoped to your origin, a lookalike domain receives nothing — that is the entire phishing-resistance property, and no SMS code or TOTP can match it.

Two caveats remain. Recovery is the hard part: a user who loses every device needs backup codes or a second factor, or they are locked out forever. And cross-device sync is provider-specific— a passkey in iCloud Keychain does not appear in Android's password manager. Offer at least two credentials per account and a tested recovery path before making passkeys the only door.

In NIST's terms, WebAuthn with a platform authenticator reaches AAL2 and hardware authenticators reach AAL3 — the strongest assurance level defined. SMS OTP, by contrast, has been disallowed for AAL2 since 2016.

API Keys: Machine Authentication, Not User Authentication

An API key is a static bearer string identifying a caller — a service, a tenant, a job. It proves no user identity, carries no user consent, and cannot be revoked per-action. Used for what it is — server-to-server calls, billing metering, rate-limit attribution — it is fine. Used to authenticate a person, it is a category error: the key lives somewhere, and wherever it lives, it leaks.

Treat keys accordingly: generate 256-bit values, store only a hash, display the value exactly once, scope per environment (sk_live vs sk_test), support overlapping rotation windows, and never send them from browser code — anything a person uses should be a session, OIDC login, or passkey instead.

Head-to-Head Comparison

The table below is the decision surface. Read it by looking for the row whose “best for” matches your situation, then verify the revocation and phishing columns against your threat model.

MethodStateRevocationPhishingComplexityBest for
Server sessionServer-sideInstantMediumLowFirst-party web apps
JWT access tokenStatelessAt expiry onlyMediumMediumService-to-service, SPAs with rotation
OAuth 2.0 + OIDCAt the IdPToken + IdP revocationMediumHighSSO, third-party access
Passkey (WebAuthn)User device + server keyDelete stored keyResistantMediumPasswordless login, MFA
API keyStatic, caller-heldRotate the keyN/AVery lowMachine-to-machine calls

Verdict: server-side sessions win for a first-party web app; a short-lived JWT access token plus rotating refresh token wins for SPAs and native clients; OIDC delegated to an identity provider wins for enterprise SSO; passkeys win as the phishing-resistant factor everyone should be shipping; API keys are for machines and nothing else.

The Modern Recommendation Pattern

By application type, the pattern most security teams converged on by 2026 looks like this:

  1. First-party web app (server-rendered or hybrid): an httpOnly, Secure, SameSite=Lax session cookie backed by server state, rotated on login.
  2. SPA or native client: a 5–15 minute access token plus a refresh token with rotation and reuse detection. Keep the refresh token in an httpOnly cookie or the platform secure store — never localStorage.
  3. Enterprise SSO: OIDC authorization code + PKCE against your customer's IdP, with SCIM for provisioning. Do not build a SAML parser.
  4. Passwordless: passkeys as the primary factor, with TOTP or security keys as fallback and printable backup codes generated at enrollment.
  5. Internal services: mTLS or short-lived signed JWTs; API keys only for external partner integrations where mutual TLS is impractical.

Note what this list is not: one mechanism everywhere. Authentication for humans, delegation to third parties, and identification of machines are three jobs. Frameworks that collapse them — treating an OAuth access token as a session, or an API key as a user — accumulate security debt quietly and pay it back loudly.

Layering MFA on Top

Whatever primary mechanism you choose, MFA is a separate layer evaluated by risk, not a checkbox on the login form: two factors from different categories (something you know, have, are), with step-up triggered by signal — new device, unusual geography, a first access to an admin surface, a large export. Factor strength, roughly: WebAuthn/passkeys > signed push with number matching > TOTP apps > SMS OTP — still better than nothing, still phishable in real time by proxy kits. Recovery codes are part of MFA, not an afterthought: issue them at enrollment, force one-time display, and log every consumed code.

Security Checklist

The following items catch the majority of real-world authentication vulnerabilities. Most are configuration, not architecture:

  • Cookie flags: HttpOnly, Secure, explicit SameSite, tight Path / Max-Age, no sensitive data inside.
  • PKCE everywhere: S256 for every public client; no implicit flow, no password grant.
  • Token hygiene: 5–15 minute TTLs, refresh rotation with reuse detection, audience and issuer checks, algorithm allowlist.
  • Key rotation: signing keys on a schedule (quarterly), JWKS cached with a short TTL, API keys hashed with overlapping rotation windows.
  • Session lifecycle: regenerate the ID on login, set an absolute lifetime, and make “sign out everywhere” actually delete state.
  • Rate limiting and monitoring: throttle credential endpoints per IP and account, alert on stuffing patterns, log every auth event and outcome.

Conclusion

There is no universally best authentication method, because sessions, JWTs, OAuth, passkeys, and API keys answer different questions — and the most common failures come from using one to answer another's question. The converged pattern is unglamorous: sessions for first-party web, short-lived access tokens with rotating refresh tokens for clients, OIDC delegated to an identity provider for SSO, passkeys for passwordless login, API keys strictly for machines.

Pick per surface, not per company — then let expiry, rotation, and revocation do the work long-lived bearer tokens never could.

Key Takeaways

  • Sessions, JWTs, OAuth/OIDC, passkeys, and API keys solve different problems — match the tool to the question, not to a tutorial.
  • Server-side sessions are the safest default for first-party web: instant revocation, no JS-readable token, one place to audit.
  • JWTs work as 5–15 minute access tokens; as session tokens they inherit unsolvable revocation and key-rotation problems.
  • OAuth 2.0 authorizes, OIDC authenticates — reading identity from an access token is undefined behavior.
  • Passkeys are the only mainstream phishing-resistant factor; plan recovery before making them the only door.

FAQ

JWT vs session cookie: which should I use?

Use a server-side session cookie for a first-party web app — instant revocation and no JavaScript-readable token. Use a short-lived JWT access token plus rotating refresh token for SPAs, native apps, and service-to-service calls, where stateless verification scales without a shared lookup.

Is OAuth used for login?

OAuth 2.0 is an authorization framework, not a login protocol. To authenticate with OAuth you need OpenID Connect, which adds an id_token, a userinfo endpoint, and discovery. “Sign in with Google” is OIDC layered on OAuth 2.0.

Are passkeys safer than passwords with 2FA?

Yes for phishing: passkeys are cryptographically bound to your site's origin, so a lookalike domain cannot use them, and no shared secret exists to steal or reuse. The trade-off is account recovery, which needs backup codes or a second registered device.

How do you revoke a JWT before it expires?

You cannot, by design — a verified JWT stays valid until exp. Keep access tokens to 5–15 minutes, keep refresh tokens server-side so deleting them ends the session, or maintain a denylist, which reintroduces the state JWTs were meant to avoid.

Should API keys be used to authenticate users?

No. API keys identify machines or tenants: static bearer secrets, no user consent, coarse revocation, and they leak from configs. Use sessions, OIDC, or passkeys for people; reserve API keys for server-to-server calls with hashing, scoping, and rotation.

References

  1. IETF. "The OAuth 2.0 Authorization Framework (RFC 6749)." October 2012. datatracker.ietf.org/doc/html/rfc6749
  2. IETF. "JSON Web Token (RFC 7519)." May 2015. datatracker.ietf.org/doc/html/rfc7519
  3. IETF. "OAuth 2.0 Security Best Current Practice (RFC 9700)." January 2025. datatracker.ietf.org/doc/html/rfc9700
  4. W3C. "Web Authentication Level 3." W3C Recommendation. w3.org/TR/webauthn-3
  5. OWASP. "Session Management Cheat Sheet." Cheat Sheet Series. cheatsheetseries.owasp.org
  6. NIST. "Digital Identity Guidelines: Authentication and Lifecycle Management (SP 800-63B)." pages.nist.gov/800-63-3/sp800-63b.html
Summarize with AI
Page