Skip to content
Ting
All posts

· 10 min read

Authentication for services: sessions, JWTs and OAuth

  • Security
  • Authentication
  • OAuth
  • APIs
Contents · 10 sections
  1. 01Authentication, authorisation, and tokens
  2. 02Server-side sessions
  3. 03JWTs: what they are and aren’t
  4. 04The revocation problem
  5. 05OAuth 2.0 and OpenID Connect
  6. 06Tokens in the browser
  7. 07Service-to-service
  8. 08Authorisation is the part that gets breached
  9. 09Verifying a token properly
  10. 10References

Authentication is the part of a system where a small misunderstanding becomes a breach. Most of the mechanisms are standard and well documented, and most of the incidents come from using them for something they weren’t designed for: a JWT treated as a session, an ID token sent to an API, a token in localStorage, or an endpoint that checks who you are and never checks what you’re allowed to touch.

This article covers server-side sessions and self-contained tokens and the trade-off between them, what OAuth 2.0 and OpenID Connect actually do, where tokens should live in a browser app, how services authenticate to each other, and why authorisation, not authentication, is where most API breaches happen.

Authentication, authorisation, and tokens

  • Authentication establishes who is calling: a user, or another service.
  • Authorisation decides whether that caller may perform this action on this resource.
  • A credential proves identity once (a password, a passkey, a client secret). A token is what you get in exchange, so you don’t have to present the credential on every request.

Tokens come in two kinds, and the choice between them shapes everything else:

  • A reference token is a random, meaningless string. The server looks it up to find out who it belongs to. A session ID is a reference token.
  • A self-contained token carries its claims inside it, signed by the issuer. Any service with the issuer’s public key can verify it without a lookup. A JWT is a self-contained token.

Server-side sessions

The classic web login: after the user signs in, the server creates a session record in a database or Redis and sends its random ID in a cookie. Every request carries the cookie, and the server looks the session up.

Sessions are boring in the best way. Revoking access means deleting the record, and it takes effect on the next request. The cookie can be made inaccessible to JavaScript (HttpOnly), sent only over HTTPS (Secure), and withheld from cross-site requests (SameSite=Lax or Strict), which together remove most token-theft and cross-site request forgery risks. The cost is a lookup per request, which for one application talking to its own session store is negligible.

Two details matter: issue a new session ID at login, so an attacker can’t fix a victim’s ID in advance (session fixation), and expire sessions both after inactivity and after an absolute maximum age.

JWTs: what they are and aren’t

A JSON Web Token is three base64url-encoded parts separated by dots: a header naming the signing algorithm and key, a payload of claims such as subject (sub), issuer (iss), audience (aud) and expiry (exp), and a signature over the first two. The payload is signed, not encrypted: anyone holding the token can read it. Don’t put anything in a JWT you wouldn’t show the user.

Verifying one correctly means checking all of these, and libraries make it easy to skip some:

  • The signature, with an algorithm you chose. Historic vulnerabilities came from trusting the token’s own alg header: accepting none, or verifying an RS256 token as HS256 using the public key as the HMAC secret. Pin the allowed algorithms on the server.
  • The issuer and audience. A token validly signed by your identity provider but issued for a different API must be rejected. Without the audience check, any service that receives tokens can replay them against every other.
  • Expiry and not-before, with a small allowance for clock skew.

JWTs earn their keep when many services need to verify identity without all calling one session store: an API gateway, a fleet of microservices, or APIs at a different organisation from the identity provider. Signing keys are published as a JSON Web Key Set, and verifiers cache them, so verification is a local signature check.

The revocation problem

A self-contained token’s strength is also its weakness. Nobody looks it up, so nobody can take it back: a stolen JWT stays valid until it expires, whatever happens to the account in the meantime.

Fig. 1How long a stolen credential keeps working after you revoke the account. A self-contained token can’t be recalled, so its lifetime is your exposure.

The standard answer combines two tokens:

  • A short-lived access token, typically 5–15 minutes, verified locally by every API.
  • A long-lived refresh token, a reference token held only by the client and presented only to the authorisation server, which checks it against its database before issuing a new access token. Revoking the refresh token cuts the user off within one access-token lifetime.

Refresh token rotation strengthens this: every refresh returns a new refresh token and invalidates the old one. If an old refresh token is ever presented again, someone has a copy, and the server revokes the whole family. Where minutes of exposure are too many, such as an admin console, add a small denylist of revoked token IDs (jti) that APIs check, or use reference tokens with introspection. At that point you have rebuilt a session store, which is fine: use the right tool for the risk.

OAuth 2.0 and OpenID Connect

OAuth 2.0 (RFC 6749) is a framework for delegated authorisation: letting an application act on a user’s behalf at an API, with limited scope, without ever seeing the user’s password. The user signs in at an authorisation server they trust, approves the request, and the application receives an access token. OpenID Connect is a layer on top that adds authentication: an ID token that tells the application who signed in.

For applications with a user, the right flow today is the authorization code flow with PKCE (RFC 7636). The implicit flow, which returned tokens directly in the browser’s URL, and the password grant are deprecated in the OAuth 2.0 Security Best Current Practice (RFC 9700) and dropped from the OAuth 2.1 draft.

Sequence diagram with Browser, App backend, Authorization server and API. 1: the app generates a random verifier and redirects the browser to /authorize with a code challenge, the SHA-256 of the verifier. 2: the user signs in and consents on the authorization server's own page. 3: the authorization server redirects back with a one-time code and the state. 4: the browser delivers the code to the app's callback, and the app checks the state. 5: the app posts the code and verifier to /token over the back channel. 6: the authorization server checks the verifier against the challenge and returns access, refresh and ID tokens. 7: the app calls the API with the access token, which the API verifies locally using cached keys.
Fig. 2The authorization code flow with PKCE. The code travels through the browser; the tokens never do, and the code is useless to anyone who doesn’t hold the verifier.

Two confusions cause most OAuth bugs:

  • ID token versus access token. The ID token is addressed to the client application: it says who logged in. The access token is addressed to an API: it says what the bearer may do. APIs should accept only access tokens issued with their own audience.
  • OAuth is not a login protocol. “Log in with” built on plain OAuth, by calling some profile endpoint with an access token, has led to account takeovers when an access token issued to one app was replayed at another. Use OpenID Connect and validate the ID token’s audience.

Tokens in the browser

Anything JavaScript can read, an XSS bug can steal. That rules out localStorage and sessionStorage for long-lived tokens: one injected script, from your code or any third-party script you load, can send them anywhere.

The pattern the IETF now recommends for browser apps is the backend for frontend (BFF): the application’s own server acts as the OAuth client, keeps the access and refresh tokens server-side, and gives the browser an ordinary HttpOnly session cookie. The browser calls the BFF; the BFF attaches the access token when it calls APIs. Tokens never touch JavaScript, and the browser-facing part goes back to being a well-understood cookie session.

Service-to-service

Machine identity has its own tools:

  • Client credentials grant. A service authenticates to the authorisation server with its own credentials and receives an access token, with no user involved.
  • Mutual TLS. Both sides present certificates, so each knows exactly which workload it is talking to. Service meshes automate certificate issuance and rotation, and SPIFFE standardises workload identities.
  • Workload identity federation. Instead of storing a long-lived secret, a workload presents a short-lived token from its platform, such as a Kubernetes service account token or a cloud OIDC token, and exchanges it for credentials. No secret to leak means no secret to rotate.

When a request passes through several services on a user’s behalf, decide explicitly whether each hop carries the user’s identity, the service’s, or both. Forwarding the user’s token everywhere means every service holds a credential that works against every other; token exchange (RFC 8693) can mint a narrower token for each hop.

Authorisation is the part that gets breached

The top entry in the OWASP API Security Top 10 (2023) is broken object level authorisation: an endpoint that authenticates the caller correctly and then returns /invoices/1043 without checking that invoice 1043 belongs to them. Change the number, get someone else’s data. No token trick is needed, and no amount of JWT validation prevents it.

  • Check ownership or permission on every object, on every request, in the data access layer rather than in each handler, so a new endpoint can’t forget it.
  • Scope queries by tenant: WHERE account_id = $caller AND id = $1, not a lookup by ID followed by a check. Postgres row-level security can enforce this inside the database.
  • Return 404 rather than 403 for objects the caller can’t see, so IDs can’t be probed.
  • Use scopes in tokens for coarse permissions (“may read invoices”) and your own data for fine-grained ones (“may read this invoice”). Don’t try to fit every permission into a token; it will be stale the moment a permission changes, for exactly the reason in the revocation section.

Finally, rate-limit authentication endpoints (see the rate-limiting article), store passwords with a slow hash such as Argon2id or bcrypt, and prefer passkeys where you can: a phishing-resistant credential removes the largest category of account takeover outright.

Verifying a token properly

Access-token verification at an API with the jose library, with every check pinned on the server side, followed by the object-level authorisation that token verification alone doesn’t give you.

auth.ts
// Verify an OAuth access token (a JWT) at an API, then authorise the specific
// object, not just the caller. Uses the jose library.
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://auth.example.com/";
// Public keys from the issuer, cached; refetched when a token names an unknown key id.
const JWKS = createRemoteJWKSet(new URL(ISSUER + ".well-known/jwks.json"));

type Caller = { userId: string; accountId: string; scopes: Set<string> };

export async function authenticate(req: Request): Promise<Caller> {
  const header = req.headers.get("authorization") ?? "";
  if (!header.startsWith("Bearer ")) throw new HttpError(401, "missing bearer token");
  const { payload } = await jwtVerify(header.slice(7), JWKS, {
    issuer: ISSUER,                       // signed by our authorisation server...
    audience: "https://api.example.com",  // ...for this API, not for some other one
    algorithms: ["ES256", "RS256"],       // never "none", never let the token pick HS256
    clockTolerance: 30,                   // seconds of clock skew allowed on exp and nbf
  });
  return {
    userId: String(payload.sub),
    accountId: String(payload.account_id),
    scopes: new Set(String(payload.scope ?? "").split(" ")),
  };
}

// Authentication said who is calling. Authorisation must still check that this
// caller may touch this particular invoice, on every request.
export async function getInvoice(req: Request, invoiceId: string) {
  const caller = await authenticate(req);
  if (!caller.scopes.has("invoices:read")) throw new HttpError(403, "insufficient scope");
  const invoice = await db.invoices.find(invoiceId);
  // 404, not 403, so the response doesn't reveal that someone else's invoice exists.
  if (!invoice || invoice.accountId !== caller.accountId) throw new HttpError(404, "not found");
  return invoice;
}

References

  1. Hardt, D. (2012). RFC 6749: The OAuth 2.0 Authorization Framework. IETF. rfc-editor.org/rfc/rfc6749
  2. Sakimura, N., Bradley, J. & Agarwal, N. (2015). RFC 7636: Proof Key for Code Exchange by OAuth Public Clients. IETF. rfc-editor.org/rfc/rfc7636
  3. Lodderstedt, T., Bradley, J., Labunets, A. & Fett, D. (2025). RFC 9700: Best Current Practice for OAuth 2.0 Security. IETF. rfc-editor.org/rfc/rfc9700
  4. Jones, M., Bradley, J. & Sakimura, N. (2015). RFC 7519: JSON Web Token (JWT). IETF. rfc-editor.org/rfc/rfc7519
  5. Sheffer, Y., Hardt, D. & Jones, M. (2020). RFC 8725: JSON Web Token Best Current Practices. IETF. rfc-editor.org/rfc/rfc8725
  6. OpenID Foundation. OpenID Connect Core 1.0. openid.net
  7. IETF OAuth Working Group. OAuth 2.0 for Browser-Based Applications (draft). datatracker.ietf.org
  8. OWASP. API Security Top 10 (2023): API1 Broken Object Level Authorization. owasp.org/API-Security