JWT vs Sessions, OAuth 2.1 vs OIDC Explained (2026): Tokens, PKCE, JWKS

Nov 10, 2026 · 13 min · Ajay Kumar

Every few months someone asks me whether they should "use JWTs or sessions", and the honest answer is that the question mixes up three different things: how a server remembers you, how one system delegates access to another, and what format a token happens to be written in. I have built login for SaaS dashboards, internal tools and APIs that agents call, and the bugs I see are almost never about the format. They come from treating an ID token as an access token, keeping a long-lived refresh token in localStorage, or letting a token's own header choose how it gets verified. This post explains JWT vs sessions, OAuth 2.1 vs OpenID Connect, the three token types, PKCE, browser token storage, JWKS rotation and revocation, where passkeys fit, and the JWT vulnerabilities that are still being filed in 2026. If you are staring at a broken token right now, the companion guide to fixing JWT invalid signature, expired and kid errors is the practical half.

JWT vs session cookies: what is actually different?

A session is a random identifier in a cookie; the server holds everything else. Validating a request is a lookup in Redis or Postgres, and logging someone out is deleting a row.

A JWT, defined in RFC 7519, is a signed set of claims (sub, iss, aud, exp and friends), so any service holding the right key can check it without calling home. That is the appeal: no shared session store. The cost is the mirror image: because nobody looks anything up, nobody can easily un-issue it. A JWT that is valid for 24 hours stays valid for 24 hours after the user clicks "log out", unless you add back the very lookup you were trying to avoid.

So the real decision is not about format, it is about who needs to validate the credential:

  • One web app talking to its own backend: a server-side session in an HttpOnly cookie is simpler and easier to revoke. Most monoliths and many SaaS dashboards should stop here.
  • Many APIs, or APIs owned by other teams or companies: short-lived signed access tokens make sense, because the alternative is every service calling a central session store.
  • Both, which is common: the browser gets a session cookie, and the backend swaps it for short-lived tokens when it calls downstream APIs. That is exactly the Backend for Frontend pattern I come back to below.

OAuth 2.1 vs OAuth 2.0 vs OpenID Connect

OAuth is about delegation: a client gets an access token so it can call an API on a user's (or its own) behalf. It says nothing about who the user is. OpenID Connect sits on top of OAuth and adds identity: an ID token, a userinfo endpoint, discovery and logout. If you want "Sign in with X", you want OIDC. If you want "let this app read my calendar", you want OAuth. Most real logins use both in one flow.

OAuth 2.1 is still an Internet-Draft. The current revision, draft-ietf-oauth-v2-1-17, is dated 31 October 2026 and remains a working group document, not an RFC. It is not a new protocol; it folds a decade of patches into one text. Its non-normative list of differences from OAuth 2.0 includes:

  • PKCE is part of the authorization code grant by default, and the plain PKCE method is removed.
  • Redirect URIs are compared by exact string match.
  • The implicit grant (response_type=token) and the resource owner password credentials grant are gone.
  • Bearer tokens may no longer travel in a URL query string.
  • Refresh tokens for public clients must be sender-constrained or one-time use.

None of that waits on the draft. Every rule above already appears in [RFC 9700] (dropping plain as a SHOULD to use S256)(https://www.rfc-editor.org/rfc/rfc9700), the OAuth 2.0 Security Best Current Practice published in January 2025, so you can and should build to it today. OpenID Connect, meanwhile, is about as settled as standards get: OIDC Core 1.0 with errata set 2 was published as ISO/IEC 26131:2024, alongside discovery, dynamic registration and the logout specs.

If you need the deeper OAuth plumbing, such as protected resource metadata and dynamic client registration, my MCP authorization explainer walks through it with a real consumer of these specs.

Access token vs ID token vs refresh token

Access token ID token Refresh token
Defined by RFC 6749; JWT profile in RFC 9068 OIDC Core 1.0 RFC 6749, hardened by RFC 9700
Audience (aud) The API (resource server) The client app (its client_id) The authorization server only
Who reads it The API The client, once, after login Nobody but the token endpoint
Format Opaque or JWT (typ: at+jwt in RFC 9068) Always a JWT Usually opaque
Typical lifetime Minutes Minutes; used once Hours to days, rotated
Send to an API? Yes, in Authorization: Bearer No No
Revocation Short expiry, introspection (RFC 7662) Not applicable RFC 7009 revocation, rotation

The rule worth tattooing somewhere: an ID token tells the client who logged in; it is not permission to call an API. An API that accepts ID tokens is accepting a credential minted for someone else, which is exactly what the aud check exists to stop. RFC 9068 adds explicit typing for the same reason: resource servers must reject tokens whose typ is not at+jwt or application/at+jwt, so an ID token cannot be replayed as an access token even when both are signed by the same key.

What is PKCE, and why OAuth 2.1 requires it

PKCE (RFC 7636) closes a simple hole. The authorization code comes back through a redirect, which can leak via logs, history or a malicious app registered for the same URL scheme, and without PKCE whoever holds the code can redeem it. With PKCE, the client first generates a random code_verifier, sends only its SHA-256 hash (the code_challenge) on the authorization request, and must present the original verifier when it redeems the code. A stolen code is useless without the verifier, which never left the client.

It costs two lines. This recipe was checked against the RFC 7636 Appendix B test vector on OpenSSL 3.6.5:

# PKCE S256 with OpenSSL 3.x (RFC 7636). Verifier: 43 to 128 unreserved characters.
code_verifier=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')   # 64 chars
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

# Authorization request carries the challenge, never the verifier:
#   GET /authorize?response_type=code&client_id=web&redirect_uri=https%3A%2F%2Fapp.example%2Fcb
#       &scope=openid%20profile&state=...&code_challenge=$code_challenge&code_challenge_method=S256
# Token request proves possession of the verifier:
#   POST /token  grant_type=authorization_code&code=...&code_verifier=$code_verifier

# Sanity check against RFC 7636 Appendix B: prints E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
printf '%s' "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='

PKCE is not only for mobile and single-page apps. RFC 9700 recommends it for confidential clients too, because it also defeats authorization code injection, and OAuth 2.1 simply makes it the default for everyone.

Where should a browser app store tokens?

For years the debate was "localStorage or cookie", and both sides were half right. In August 2026 the IETF settled it as far as a standard can: RFC 10017, OAuth 2.0 for Browser-Based Applications, published as a Best Current Practice. It describes three architectures, ordered from most to least secure:

  1. Backend for Frontend (BFF). A server-side component is the OAuth client (a confidential one, with its own credentials), keeps all tokens, and gives the browser only a session cookie. The browser calls the BFF; the BFF calls the APIs. The RFC strongly recommends this for business applications, sensitive applications and anything handling personal data.
  2. Token-mediating backend. The backend obtains tokens and hands the access token to the browser, which calls APIs directly. The refresh token stays on the server.
  3. Browser-based OAuth client. The JavaScript app is a public client and holds everything itself.

The reasoning is the bit people skip. If an attacker gets JavaScript running in your origin, through XSS or a compromised npm dependency, localStorage, sessionStorage, IndexedDB and in-memory variables all fall to it. The only way to keep tokens out of reach is not to give them to the browser, which is what the BFF does. The XSS still hurts while the tab is open, but nobody walks away with a refresh token that works for a week.

For the BFF's cookie, RFC 10017 says Secure and HttpOnly MUST be set, SameSite=Strict SHOULD be, the __Host-Http- name prefix SHOULD be used, Path=/ SHOULD be set, and no Domain attribute. If you do run a pure browser client, the same RFC requires the authorization server to either rotate refresh tokens or sender-constrain them, and to cap their lifetime so rotation cannot extend a session forever.

JWKS rotation and revocation in practice

An OIDC provider publishes its public keys as a JSON Web Key Set (RFC 7517) at the jwks_uri listed in its discovery document. Each token's header carries a kid saying which key signed it. OIDC Core section 10.1.1 spells out rotation: the provider adds a new key to the set, starts signing with it whenever it likes, and verifiers that see an unfamiliar kid go back and refetch the set. Old keys should stay published for a while so tokens already in flight still verify.

That puts two jobs on you: cache the JWKS, and refetch on an unknown kid with a cooldown so junk tokens cannot turn your API into a denial-of-service cannon aimed at your identity provider. Mature libraries do both; the guide shows the exact jose and PyJWT settings.

Revocation is where JWTs earn their reputation. Your options, roughly in the order I reach for them:

  • Short access token lifetimes, five to fifteen minutes, so "revoked" means "expires soon".
  • Refresh token rotation and revocation (RFC 7009), so logout kills the thing that mints new access tokens.
  • Introspection (RFC 7662) or opaque tokens for high-value APIs, accepting a network call per request (cache it briefly).
  • A deny list keyed on jti for the rare emergency, kept small because entries only need to live until exp.
  • Sender-constrained tokens with DPoP (RFC 9449) or mTLS, so a stolen token is useless without the client's private key.

Common JWT vulnerabilities still shipping in 2026

The JWT format is fine. The trouble is that its header is attacker-controlled, and libraries keep trusting it. The classic is alg: none; the durable one is algorithm confusion, where a verifier configured with an RSA public key is tricked into treating that public key as an HMAC secret, and anyone can then mint "valid" HS256 tokens with a key that is, by definition, public.

Eleven years of the same JWT bug: verifiers that let the token pick the key type CVE-2015-9235 jsonwebtoken (Node) RSA public key accepted as HS256 secret fixed in 4.2.2 CVE-2022-21449 Oracle Java SE 17, 18 ECDSA signature with r = s = 0 accepted April 2022 patch CVE-2022-29217 PyJWT (Python) token chooses alg from all defaults: confusion fixed in 2.4.0 CVE-2023-48223 fast-jwt (Node) PEM matcher missed some public key formats fixed in 3.3.2 CVE-2024-54150 cjwt (C) HMAC vs RS/EC signatures not distinguished fixed in 2.3.0 CVE-2026-44699 libjwt (C), May 2026 RSA JWK without alg used for HS256: empty key 3.0.0 to 3.3.2 affected CVE-2026-48526 PyJWT, May 2026 public JWK accepted as HMAC secret fixed in 2.13.0 CVE-2026-102268 PyJWT, Sep 2026, critical whitespace-mutated PEM skips the 2022 guard fixed in 2.14.0 Seven of eight are HMAC vs asymmetric key confusion. The fix is the same every time: pin one algorithm per key in your code, never allow HS* and RS*/ES* in the same verify call, and keep the library current.
Eight JWT verification CVEs from 2015 to 2026, with affected and fixed versions from NVD and GitHub Security Advisories. 2026 entries are shaded.

PyJWT is the cautionary tale of the year. Its repository lists 21 security advisories published in 2026, one rated critical: CVE-2026-102268, where line-ending or whitespace changes to a PEM public key slipped past the very check added after CVE-2022-29217. Others include a JWKS client that followed redirects and empty HMAC keys accepted from a JWK. Everything up to 2.13.0 is affected by at least one high-severity issue; the current release is 2.15.1, from 28 September 2026. I read that less as "PyJWT is bad" than as what happens when a popular library gets a hard look; the safe configuration (one algorithm, one key type) dodges most of them.

The IETF is updating its own advice too. draft-ietf-oauth-rfc8725bis, which would replace RFC 8725, has been approved by the IESG and is in the RFC Editor queue. It adds guidance on case-insensitive alg matching, treating kid, jku and x5u as attacker-controlled (SSRF if you fetch them), huge PBES2 iteration counts, JWE decompression bombs and JWS JSON-vs-compact format confusion. The checklist I hand to teams is shorter:

  • Pass an explicit algorithms list to every verify call; never derive it from the token.
  • Validate iss, aud, exp and, for access tokens, typ. Library defaults vary.
  • Never fetch keys from a URL inside the token. Use the issuer's configured jwks_uri.
  • Use asymmetric signatures (RS256, ES256, EdDSA) when more than one service verifies, so verifiers cannot mint tokens.
  • Do not put secrets or personal data in a JWT payload. It is signed, not encrypted, and anyone can base64-decode it.

Do passkeys replace JWTs, sessions or OAuth?

No, and the confusion is understandable. Passkeys replace the password: the thing the user proves at login. WebAuthn Level 3 is a W3C Recommendation dated 25 August 2026, and the FIDO Alliance estimated 5 billion passkeys in use in May 2026, with 75 percent of surveyed people having enabled one on at least one account. Treat that as an industry estimate, not a measured count.

What happens after the passkey ceremony is unchanged. The server still creates a session, or the identity provider still issues an ID token, access token and refresh token through OIDC. Passkeys make phishing the login much harder; they do nothing for a refresh token stolen from localStorage an hour later. Passkeys protect the front door; the BFF protects what you hand out once someone is through it. If you sell to enterprises, the same thinking extends to SSO and SCIM, which I covered in enterprise-ready SaaS. For machines and agents rather than people, the equivalent move away from long-lived secrets is workload identity federation.

FAQ

Is JWT better than sessions?

Neither is better in general. Sessions are simpler and easier to revoke when one backend serves one web app. JWT access tokens fit when many APIs, possibly owned by different teams, need to validate a credential without a shared store. Many systems use a session cookie in the browser and JWTs between backends.

What is the difference between OAuth 2.1 and OpenID Connect?

OAuth 2.1 is an authorization framework for getting access tokens to call APIs; as of draft 17 (October 2026) it is still an Internet-Draft. OpenID Connect is an identity layer on top of OAuth that adds the ID token and user info, so an app learns who signed in. Login usually uses OIDC; API access uses OAuth.

Should I store JWTs in localStorage or cookies?

For anything sensitive, neither in JavaScript-readable form. RFC 10017 recommends a Backend for Frontend that keeps tokens on the server and gives the browser an HttpOnly, Secure, SameSite=Strict cookie. Any storage readable by your page's JavaScript can be read by an XSS payload.

Can you revoke a JWT?

Not directly, because verifiers do not call the issuer. In practice you keep access tokens short-lived, revoke or rotate the refresh token, and use token introspection or a jti deny list for high-value APIs. Sender-constrained tokens (DPoP or mTLS) make theft far less useful.

What I would do this quarter

If I inherited a login system tomorrow, I would grep every JWT verify call for a hard-coded algorithms list and an aud check, then bump PyJWT to 2.15.1 and jose to the current 6.2.x anywhere they appear. I would move any browser app holding refresh tokens to a BFF, with __Host- cookies, and set access token lifetimes to minutes. I would confirm our JWKS cache refetches on an unknown kid without hammering the provider, and turn on passkeys at the identity provider, because it is now a setting rather than a project. None of this is glamorous. It is the platform work that decides whether an XSS bug is a bad afternoon or a breach notice. When something breaks along the way, the JWT troubleshooting guide has the error messages and fixes.

Related: MCP Authorization Explained: OAuth 2.1, RFC 9728 and CIMD in 2026, How to Set Up Keycloak 26.8 for OIDC and SAML SSO: Step-by-Step (2026) and Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related