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.
| Scheme | Credential | Sending risk | Verdict |
|---|---|---|---|
Basic | Base64 of user:password | Replayable, trivially decoded | Only over TLS, and only for internal tooling |
Digest | Hashed challenge response | Better than Basic, but no protection of the body | Legacy |
Bearer | An opaque or structured token | Replayable if stolen; long-lived tokens are the real problem | Standard for APIs |
| Mutual TLS | A client certificate | Bound to a private key that never travels | Strongest, 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 Forbiddenafter a successful login means the credentials were fine and the authorisation decision was not — a different fix entirely.- Send credentials in the
Authorizationheader, never as query parameters, which leak into logs, history andReferer.
Bearer tokens, JWTs and API keys
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtmMSJ9.eyJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbSIsImV4cCI6MTc3MDAwMDAwMCwic2NvcGUiOiJvcmRlcnM6cmVhZCJ9.signature| Part | Content | Encrypted? |
|---|---|---|
| Header | Algorithm and key id | No — base64url, readable |
| Payload | Claims: sub, aud, iss, exp, scope | No — anyone with the token can read it |
| Signature | Signed with the issuer's key | Yes — 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,audandexpevery 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...| Flow | Used by | Key point |
|---|---|---|
| Authorization code + PKCE | Web and native apps on behalf of a user | The default; PKCE replaces the implicit flow entirely |
| Client credentials | Service to service, no user involved | The client authenticates as itself |
| Device code | TVs, CLIs, anything without a browser | The user approves on another device |
| Refresh token | Keeping a session alive | Rotate 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
stateparameter guards the redirect against CSRF; PKCE guards the code exchange against interception. - Mutual TLS binds a token to a client certificate (
cnfclaim), 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.Related
Cookies, sessions and state Proxies, load balancers and connection management
Last refreshed 2026-09-18.