Auth

Authentication Patterns: OAuth, JWT, Sessions

Auth is one of those topics where everyone knows enough to ship something, and almost nobody understands the trade-offs deeply enough to debug the result. The result: cargo-cult code that mostly works until it does not, at which point fixing it requires understanding the whole stack from cookies to OAuth dance.

This article is the field guide. Each pattern with its use cases, security model, and the specific failure modes that bite teams in production.

The fundamental question

Authentication answers "who are you?". Authorisation answers "are you allowed to do this?". They are different problems and different patterns address them. This article is mostly about authentication; we touch on authorisation only where it intersects.

The authentication pattern you pick depends on three things: who is the user (a person, a third-party app, a backend service), what is the trust relationship, and what is the lifetime of the session.

Sessions (the boring default)

The user logs in with username and password. The server creates a random session ID, stores it in a server-side store (Redis, Postgres, memcached), and sends the session ID back as an HTTP-only cookie. The browser sends the cookie on subsequent requests; the server looks up the session ID and finds the user.

sequenceDiagram participant U as User participant S as Server participant DB as Session store U->>S: POST /login (username, password) S->>DB: Verify credentials S->>DB: Create session ID -> user_id S-->>U: Set-Cookie: session_id=abc123 (HttpOnly, Secure) Note over U,DB: Subsequent requests U->>S: GET /api/orders (Cookie: session_id=abc123) S->>DB: Lookup session_id -> user_id S-->>U: 200 OK

Classic session auth: server holds the truth; client just holds an opaque ticket.

What sessions get right:

  • Revocation is trivial. Delete the session row; user is logged out. No outstanding tokens to track.
  • Session data is server-side. Roles, preferences, tenant context can all be stored against the session.
  • Mature tooling. Every web framework has battle-tested session middleware.

What sessions get wrong:

  • Stateful. Every request hits the session store. At scale this is a hot key on Redis. With sticky sessions and a single backend, fine. With horizontal scaling and a shared store, latency adds up.
  • Cross-domain is hard. Cookies are domain-scoped. Sharing auth between app.example.com and api.example.com requires careful cookie configuration.
  • Mobile apps awkward. Cookies in mobile apps are awkward; usually mobile apps want a token they can store and send as a header.

JWT (JSON Web Tokens)

A JWT is a self-contained token: a JSON object signed by the server. The client stores it (in localStorage or a cookie) and sends it on each request. The server verifies the signature without looking up state — the user identity is in the token.

// Decoded JWT payload
{
  "sub": "user-42",
  "email": "you@example.com",
  "role": "admin",
  "exp": 1735689600
}
// Wire format: header.payload.signature, all base64url encoded

What JWT gets right:

  • Stateless. No session store lookup per request.
  • Self-contained. The token holds the user info; the server can authorise without a database hit.
  • Cross-domain friendly. Send it as an Authorization header; works the same everywhere.

What JWT gets wrong:

  • Revocation is hard. The token is valid until it expires. To revoke before expiry, you need a denylist — which makes the system stateful again, defeating the original benefit.
  • Tokens grow. Roles, permissions, tenant data — all of it bloats the token, which travels on every request.
  • Expiry trade-off. Short-lived tokens (15 min) need refresh tokens, which are sessions in disguise. Long-lived tokens (days) make revocation worse.
  • Algorithm confusion. JWT supports many signing algorithms including none (no signature). Misconfigured libraries have shipped vulnerabilities here. Always pin the algorithm explicitly.

JWTs are most often misused as session replacements when sessions would have been simpler. They shine specifically when a service needs to verify a token without coordinating with the issuer — for example, a downstream microservice trusting a token signed by an upstream auth service.

OAuth 2.0

OAuth is not authentication. OAuth is authorisation delegation: a way for an app to ask the user's permission to access their data on another service.

The flow most people use (authorisation code with PKCE):

sequenceDiagram participant U as User participant A as App participant Auth as Auth server (Google, GitHub) participant API as Resource API U->>A: Click Sign in with Google A->>Auth: Redirect with client_id, scope, code_challenge Auth->>U: Login + consent screen U->>Auth: Approve Auth->>A: Redirect with auth code A->>Auth: POST /token (auth code + code_verifier) Auth-->>A: access_token + refresh_token A->>API: GET /userinfo (Bearer access_token) API-->>A: User data

OAuth 2.0 authorisation code with PKCE. The app never sees the user's password; the user explicitly consents to specific scopes.

OAuth scopes (read:user, write:posts) define what the access token can do. Tokens have short lifetimes; refresh tokens get new access tokens.

OpenID Connect (OIDC) is OAuth + identity. The auth server returns an ID token (a JWT containing the user's identity) alongside the access token. Most "Sign in with Google" flows in 2026 are OIDC, not raw OAuth.

API keys

For service-to-service auth, an API key is often the right answer: a long random string, included as a header, valid until rotated. No flow, no expiry, no refresh. Simple.

GET /api/orders
Authorization: Bearer sk_live_a1b2c3d4e5f6g7h8

API keys make sense for:

  • Backend-to-backend communication where you control both sides.
  • Long-lived integrations like a webhook from your CRM to your data warehouse.
  • SDK auth like Stripe's sk_live_ keys.

The pitfalls: keys often end up in source code, get scraped from public GitHub repos, and are rarely rotated. Treat them as secrets; use a secret manager (Vault, AWS Secrets Manager); rotate at least annually.

mTLS (mutual TLS)

Both sides of the connection present certificates. The server validates the client's certificate against a trusted CA. Authentication happens at the TLS handshake; the application sees an authenticated identity from the connection itself.

Used for:

  • Service mesh. Istio, Linkerd, Consul Connect all use mTLS for service-to-service auth.
  • Banking and government APIs. Where regulatory requirements mandate strong auth.
  • IoT device fleets. Each device has a cert; revoking a stolen device is a CRL update.

The pain: certificate management. Generation, rotation, distribution, expiry monitoring. Usually handled by a service mesh (which automates everything) or a dedicated PKI.

Common mistakes that ship to production

  • Storing JWTs in localStorage. XSS attacks can read localStorage. Use HTTP-only cookies for session-equivalent JWTs.
  • Not setting Secure and SameSite on cookies. Cookies sent over HTTP and across origins are vulnerable to interception and CSRF. Set Secure; SameSite=Lax by default.
  • Mixing OAuth scopes. Granting read:user when you only need read:user.email. Users see the consent screen and abandon.
  • Long-lived JWT without revocation. User logs out; their token stays valid for hours. Either use short tokens with refresh, or accept a revocation store.
  • API keys in source code. Caught immediately by GitGuardian and similar scanners; if your secret is in git history, rotate it.
  • Trusting JWT's alg header. Pin the algorithm in your verification code. Do not let the token tell you how it was signed.

How to choose

  • Web app, single domain, simple: Sessions with HTTP-only cookies. Boring and right.
  • Mobile app talking to your API: JWT with refresh, OR sessions with a token sent in the Authorization header.
  • Allowing third-party apps to act on user's behalf: OAuth 2.0 with OIDC for identity. Use an off-the-shelf provider (Auth0, Clerk, Supabase Auth, AWS Cognito) rather than building yourself.
  • Internal microservices: mTLS via service mesh, or API keys. Skip JWT for this; the complexity is not worth it.
  • External webhooks: HMAC-signed payloads with a shared secret. Better than API keys for this case.

Frequently Asked Questions

Should I build my own auth?

For a hobby project, yes — it is educational. For a real product, almost never. Auth0, Clerk, Supabase Auth, AWS Cognito, Firebase Auth, and Stytch all handle the edge cases (account recovery, MFA, breach response, password policies) that you will not get right yourself. Cost is $0–25/month for small apps; cheap insurance.

What is OIDC and how is it different from OAuth?

OAuth 2.0 is for authorisation (delegated access). OpenID Connect (OIDC) is built on OAuth 2.0 to add authentication (identity). Most "social login" flows are OIDC: the app receives an ID token containing the user's identity claims, plus an access token for API calls.

Are JWTs secure?

Yes if used correctly. Most JWT vulnerabilities are misconfiguration: using insecure algorithms, not validating expiry, storing in vulnerable client storage, or trusting unsigned tokens. Use a battle-tested library, pin your algorithm, validate every claim.

How do I implement MFA?Use TOTP (Google Authenticator, Authy compatible) as the default. Add WebAuthn (passkeys) for users who want phishing-resistant MFA. SMS is the last-resort fallback — less secure but more accessible. Auth providers handle all three out of the box; if rolling your own, use an established library.

What happens when a JWT is leaked?An attacker can use it until it expires. With short-lived tokens (15 min) and a refresh-token rotation strategy, the damage window is limited. With long-lived tokens, you need a denylist or rotation of the signing key (which invalidates all outstanding tokens, log out all users).

Share your thoughts

Worked with this in production and have a story to share, or disagree with a tradeoff? Email us at support@mybytenest.com — we read everything.