Authentication on the wire

Basic and Digest, Bearer tokens and JWT structure, API keys, OAuth 2.1 flows and scopes, mutual TLS, and how WWW-Authenticate drives the flow.

Challenge and response

GET /admin HTTP/1.1
Host: app.example.com

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="admin", charset="UTF-8"

GET /admin HTTP/1.1
Host: app.example.com
Authorization: Basic YWRhOnMzY3JldA==

The WWW-Authenticate header is what turns a 401 into a usable protocol: it names the scheme and its parameters, so a client knows how to try again. A 401 without it is a dead end.

SchemeCredentialSending riskVerdict
BasicBase64 of user:passwordReplayable, trivially decodedOnly over TLS, and only for internal tooling
DigestHashed challenge responseBetter than Basic, but no protection of the bodyLegacy
BearerAn opaque or structured tokenReplayable if stolen; long-lived tokens are the real problemStandard for APIs
Mutual TLSA client certificateBound to a private key that never travelsStrongest, highest operational cost
  • Base64 is an encoding, not encryption. Anyone on the path can read a Basic credential that is not protected by TLS.
  • 403 Forbidden after a successful login means the credentials were fine and the authorisation decision was not — a different fix entirely.
  • Send credentials in the Authorization header, never as query parameters, which leak into logs, history and Referer.

Bearer tokens, JWTs and API keys

Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtmMSJ9.eyJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbSIsImV4cCI6MTc3MDAwMDAwMCwic2NvcGUiOiJvcmRlcnM6cmVhZCJ9.signature
PartContentEncrypted?
HeaderAlgorithm and key idNo — base64url, readable
PayloadClaims: sub, aud, iss, exp, scopeNo — anyone with the token can read it
SignatureSigned with the issuer's keyYes — this is what makes it trustworthy
// server side: verify before trusting a single claim
import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://login.example.com/.well-known/jwks.json'));

export async function requireScope(token, scope) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://login.example.com',
    audience: 'api.example.com'
  });
  const granted = String(payload.scope || '').split(' ');
  if (!granted.includes(scope)) throw Object.assign(new Error('forbidden'), { status: 403 });
  return payload;
}
  • Verify signature, iss, aud and exp every time. Skipping the audience check is how a token minted for another service gets accepted.
  • Never put secrets in a JWT payload: it is signed, not encrypted. Anyone holding it can read the claims.
  • Prefer short access tokens (minutes) plus a refresh flow. Revocation is the weakness of stateless tokens — a denylist or short lifetime is the mitigation.
  • API keys identify a client, not a user. Treat them as long-lived secrets: hash them at rest, show them once, rotate on a schedule.

OAuth 2.1, scopes and mutual TLS

GET /authorize?response_type=code&client_id=app123&
    redirect_uri=https://app.example/cb&scope=orders:read%20orders:write&
    state=x8f2...&code_challenge=E9Melhoa...&code_challenge_method=S256 HTTP/1.1

POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlO&code_verifier=dBjftJeZ...
FlowUsed byKey point
Authorization code + PKCEWeb and native apps on behalf of a userThe default; PKCE replaces the implicit flow entirely
Client credentialsService to service, no user involvedThe client authenticates as itself
Device codeTVs, CLIs, anything without a browserThe user approves on another device
Refresh tokenKeeping a session aliveRotate on use, and detect reuse as a breach signal
  • Scopes are the authorisation contract. Ask for the narrowest set — a token that can delete does not belong in a read-only dashboard.
  • The state parameter guards the redirect against CSRF; PKCE guards the code exchange against interception.
  • Mutual TLS binds a token to a client certificate (cnf claim), which makes a stolen token useless without the private key.
  • You do not need OAuth to protect a first-party session: a server-side session cookie is simpler and safer.
⚠️
An access token is a bearer credential: whoever holds it is the user. Treat every leak path as a full compromise — browser storage readable by script, a token in a URL, a token written to an application log, or a refresh token without rotation.

FAQ

Where should a web app keep its access token?
In memory, refreshed through an HttpOnly, Secure, SameSite cookie. That combination survives XSS better than localStorage, which any injected script can read.
401 or 403?
401 means the request lacks valid credentials — send WWW-Authenticate and expect a retry. 403 means the identity is known and simply not permitted; retrying with the same credentials will never help.

Cookies, sessions and state Proxies, load balancers and connection management

Last refreshed 2026-09-18.