JWTs, access tokens, sessions, OAuth and Zero Trust
Almost every system that has users ends up answering the same question thousands of times a second: who is making this request, and are they allowed to?
The vocabulary around that question is badly tangled. A JWT is a format, an access token is a role, OAuth is a delegation framework, OpenID Connect is a login protocol, a session is server-side state, and Zero Trust is an architecture. They get used as synonyms, and most token-related bugs start there.
This article takes them apart one at a time, from the bytes of a JWT to how a request is evaluated in a Zero Trust network. The code runs with the Python standard library and OpenSSL.
The vocabulary
| Term | What it is | Not to be confused with |
|---|---|---|
| Authentication (authn) | Proving who you are | Authorisation |
| Authorisation (authz) | Deciding what you may do | Authentication |
| Credential | A long-term secret or key: password, passkey, private key | A token |
| Token | A short-lived artefact issued after a credential is checked | A credential |
| Session | A continuing authenticated relationship | A cookie |
| Cookie | A browser storage and transport mechanism | A session |
| JWT | A signed or encrypted data format | An access token |
| Access token | A role: permission presented to an API | A JWT, which is only one possible format |
| Refresh token | A role: permission to get new access tokens | An access token |
| ID token | A role: a statement to a client that a user logged in | An access token |
| OAuth 2.0 | Delegated authorisation | Login |
| OpenID Connect | An identity layer on OAuth | OAuth |
| Zero Trust | An architecture with no implicit trust from location | A product |
Two distinctions carry most of the weight.
Format versus role. A token’s format is how it is encoded. Its role is what it is used for. A JWT can be an ID token, an access token, a logout token, or a client assertion. An access token can be a JWT or a random string.
Bearer versus sender-constrained. A bearer token works for whoever holds it, like cash. A sender-constrained token also requires proof of possession of a key, like a card with a PIN.
Why tokens exist at all
Consider the simplest possible scheme, sending a password on every request. It has several problems:
- The password is exposed at every service that receives it.
- Every service has to verify it, so each needs access to credentials.
- It cannot be limited: the password grants everything the user can do.
- Revoking access means changing the password everywhere.
- A slow password hash on every request is expensive by design.
Tokens solve this by exchanging one strong check for a narrower, shorter-lived artefact. A good token has limits a password does not: an expiry, an audience, and a scope.
There are two basic designs, and everything else is a variation of them.
| Reference token | Self-contained token | |
|---|---|---|
| Contains | A random identifier | The claims themselves, signed |
| Verified by | Looking it up | Checking a signature |
| Revocation | Immediate | Hard |
| Network call on each use | Yes, or a cache | No |
| Size | Small | Larger |
| Readable by the holder | No | Yes, unless encrypted |
| Examples | Session ID, opaque access token | JWT, PASETO, Kerberos ticket |
This is one trade-off that appears again and again in this article: a stored lookup gives you control, and a signed statement gives you independence. You cannot fully have both.
Server-side sessions
A classic web session is a reference token in a cookie.
1. POST /login (password, then MFA)
2. Server creates a session row
3. Set-Cookie: __Host-sid=<random>; Secure; HttpOnly; SameSite=Lax; Path=/
4. Browser sends the cookie with every request
5. Server looks it up, and finds the user
The session table
In PostgreSQL, a session store needs little more than this:
CREATE TABLE sessions (
id_hash bytea PRIMARY KEY, -- SHA-256 of the session ID
user_id bigint NOT NULL REFERENCES users (id),
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
auth_level smallint NOT NULL, -- how strongly they logged in
auth_time timestamptz NOT NULL, -- when they last proved it
device text,
revoked_at timestamptz
);
CREATE INDEX ON sessions (user_id) WHERE revoked_at IS NULL;
-- On each request
SELECT user_id, auth_level, auth_time
FROM sessions
WHERE id_hash = sha256($1)
AND revoked_at IS NULL
AND last_seen_at > now() - interval '1 hour'
AND created_at > now() - interval '24 hours';
-- Log out everywhere
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL;
Two details are easy to miss.
Store a hash, not the ID. If the table leaks through a backup or a SQL injection, raw IDs are immediately usable as logins. Hashes are not. A fast hash is correct here, unlike with passwords, since the ID is already high-entropy random data.
Use a CSPRNG. NIST SP 800-63B and OWASP put the floor at 64 bits of entropy. In practice, use 256 bits from secrets.token_urlsafe(32) or an equivalent. A counter or a timestamp-based ID is guessable.
Timeouts
There are two independent timers:
- Idle timeout ends a session that has gone quiet.
- Absolute timeout ends a session regardless of activity, which limits a stolen session that an attacker keeps alive.
NIST SP 800-63B-4 gives these as recommendations:
| Assurance level | Overall reauthentication | Inactivity |
|---|---|---|
| AAL2 | Up to 24 hours | Up to 1 hour |
| AAL3 | Up to 12 hours | Up to 15 minutes |
Banking, admin consoles, and similar systems often use much shorter limits.
Cookie attributes
| Attribute | Effect |
|---|---|
Secure | Only sent over HTTPS |
HttpOnly | Not readable from JavaScript |
SameSite=Lax | Not sent on most cross-site requests |
SameSite=Strict | Not sent on any cross-site request |
SameSite=None | Sent cross-site, and needs Secure |
Domain | Widens scope to subdomains, usually best left unset |
Path | Narrows the path, and is not a security boundary |
Max-Age | Lifetime. Without it, a session cookie |
The __Host- name prefix is a useful hardening step. A browser accepts such a cookie only if it is Secure, has Path=/, and has no Domain. That stops a compromised sibling subdomain from planting it.
Session attacks
Theft. XSS cannot read an HttpOnly cookie, but it can make requests from inside the page. Malware can steal cookies from disk. Support files can leak them too: in the 2023 Okta support system breach, attackers took session tokens from uploaded HAR files. A HAR is a browser network log, and it includes cookies and headers.
Phishing proxies. An adversary-in-the-middle page relays the real login, including MFA, and captures the resulting cookie. Phishing-resistant authenticators such as passkeys stop this at login, because the credential is bound to the real origin. They do not protect a session that was stolen later.
Fixation. An attacker gets a victim to log in using a session ID that the attacker already knows. The defence is to issue a new session ID at login and at each privilege change.
CSRF. A cross-site request carries the victim’s cookie automatically. Defences are SameSite, anti-CSRF tokens, and checking Origin or Sec-Fetch-Site headers. CSRF exists because the cookie is sent automatically, so a token sent by hand in an Authorization header is not vulnerable to it.
Subdomain takeover. A cookie with Domain=example.com goes to every subdomain, including forgotten ones.
Sessions at scale
A session store is a dependency on every request. With a database behind it, the usual pattern is a cache in front. It has good properties:
- Revocation is immediate, or as fast as the cache allows.
- You can list a user’s devices and end one of them.
- Changing a user’s role takes effect at once.
- A session can hold server-side data with no size limit.
It does not suit every architecture. Each service has to reach the store, which is awkward across organisations and regions. That is the gap self-contained tokens fill.
JWT: the format
A JSON Web Token, RFC 7519, is a set of claims in a compact form. It is built on a family of standards:
| RFC | Name | Purpose |
|---|---|---|
| 7515 | JWS | Signed data |
| 7516 | JWE | Encrypted data |
| 7517 | JWK | Keys as JSON |
| 7518 | JWA | The algorithms |
| 7519 | JWT | Claims, on top of JWS or JWE |
| 8725 | JWT BCP | How to use it safely |
The usual form is a JWS with three base64url parts, joined by dots:
header.payload.signature
Building one by hand
This program creates and verifies an HS256 JWT with nothing but the standard library, then tries to break it.
import base64, hashlib, hmac, json, secrets
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64url(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def enc(obj):
return b64url(json.dumps(obj, separators=(",", ":")).encode())
def sign(claims, key):
head_body = enc({"alg": "HS256", "typ": "JWT"}) + "." + enc(claims)
tag = hmac.new(key, head_body.encode(), hashlib.sha256).digest()
return head_body + "." + b64url(tag)
def verify(token, key, issuer, audience, now, leeway=30):
head, body, tag = token.split(".")
if json.loads(unb64url(head)).get("alg") != "HS256": # pinned by the verifier
raise ValueError("unexpected alg")
want = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(want, unb64url(tag)): # constant time
raise ValueError("bad signature")
claims = json.loads(unb64url(body)) # only now trust the body
if claims["iss"] != issuer:
raise ValueError("wrong issuer")
aud = claims["aud"]
if audience not in ([aud] if isinstance(aud, str) else aud):
raise ValueError("wrong audience")
if now >= claims["exp"] + leeway:
raise ValueError("expired")
return claims
key = b"k" * 32
now = 1793500000
claims = {"iss": "https://id.example", "sub": "user-42", "aud": "api://orders",
"exp": now + 300, "iat": now, "jti": "a1b2c3", "scope": "orders:read"}
token = sign(claims, key)
print(token)
print(len(token), "bytes")
head, body, tag = token.split(".")
print(unb64url(head))
print(unb64url(body))
print("ok:", verify(token, key, "https://id.example", "api://orders", now)["sub"])
changed = json.loads(unb64url(body))
changed["scope"] = "orders:admin"
forged = f"{head}.{enc(changed)}.{tag}"
none = f"{enc({'alg': 'none'})}.{body}."
attempts = [
("tampered payload", forged, key, "api://orders", now),
("alg none", none, key, "api://orders", now),
("wrong audience", token, key, "api://billing", now),
("expired", token, key, "api://orders", now + 331),
("wrong key", token, b"x" * 32, "api://orders", now),
]
for name, candidate, k, aud, when in attempts:
try:
verify(candidate, k, "https://id.example", aud, when)
print(name, "-> ACCEPTED")
except ValueError as e:
print(f"{name} -> rejected: {e}")
big = dict(claims, groups=[f"group-{i}" for i in range(50)])
print("opaque token:", len(secrets.token_urlsafe(32)), "bytes")
print("jwt with 50 groups:", len(sign(big, key)), "bytes")
Output:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUiLCJzdWIiOiJ1c2VyLTQyIiwiYXVkIjoiYXBpOi8vb3JkZXJzIiwiZXhwIjoxNzkzNTAwMzAwLCJpYXQiOjE3OTM1MDAwMDAsImp0aSI6ImExYjJjMyIsInNjb3BlIjoib3JkZXJzOnJlYWQifQ.AD7miIJ9gqTrimlCGL2mzrQ0IYuX6XdowR7LcAqyjmg
263 bytes
b'{"alg":"HS256","typ":"JWT"}'
b'{"iss":"https://id.example","sub":"user-42","aud":"api://orders","exp":1793500300,"iat":1793500000,"jti":"a1b2c3","scope":"orders:read"}'
ok: user-42
tampered payload -> rejected: bad signature
alg none -> rejected: unexpected alg
wrong audience -> rejected: wrong audience
expired -> rejected: expired
wrong key -> rejected: bad signature
opaque token: 43 bytes
jwt with 50 groups: 997 bytes
Several things follow from those lines.
The payload is public. The middle part is encoded, not encrypted. Anyone holding the token can read it, so it should never contain anything the holder or a log reader should not see. Every eyJ at the start of a string is base64 of {", which is why JWTs are easy to find in logs.
The signature covers the encoded bytes, not the JSON meaning. A change to any byte breaks it.
Size grows quickly. Adding fifty groups took this token to nearly 1 KB, and RS256 adds about 340 characters of signature alone. These go in a header on every request, and many servers and proxies cap total header size at around 8 KB.
Registered claims
| Claim | Meaning |
|---|---|
iss | Issuer, who created this |
sub | Subject, who it is about, unique within the issuer |
aud | Audience, who it is for |
exp | Expiry time |
nbf | Not valid before |
iat | Issued at |
jti | Unique ID, for replay detection and denylists |
Times are NumericDate: seconds since the Unix epoch.
iss plus sub together identify a principal. sub alone does not, and two issuers can use the same value. Treating an email address as the identity is a recurring error: emails change, get reassigned, and may not be verified.
Common non-registered claims include:
| Claim | From | Meaning |
|---|---|---|
scope | RFC 8693, RFC 9068 | Space-separated scopes |
client_id | RFC 9068 | Which client requested it |
azp | OIDC | Authorised party |
auth_time | OIDC | When the user last authenticated |
acr | OIDC | Assurance level of the login |
amr | OIDC | Methods used, such as pwd or mfa |
nonce | OIDC | Binds an ID token to a request |
cnf | RFC 7800 | The key this token is bound to |
act | RFC 8693 | Who is acting on behalf of the subject |
Header parameters
| Header | Meaning | Risk |
|---|---|---|
alg | Signature algorithm | Attacker-controlled, must be pinned |
typ | Token type | Used to stop cross-use |
kid | Which key signed this | Attacker-controlled lookup key |
jku | URL of a key set | SSRF and forged keys if followed |
jwk | The key itself, embedded | Forgery if trusted |
x5u, x5c | Certificates | Same class of risk |
crit | Extensions that must be understood | Ignored at your peril |
The header is unauthenticated until the signature has been verified, yet it is what tells a naive verifier how to verify. Every JWT attack in the next section exploits that circle.
Signing algorithms
alg | Type | Key | Signature | Notes |
|---|---|---|---|---|
HS256 | HMAC | Shared secret | 32 bytes | Verifier can also forge |
RS256 | RSA PKCS#1 v1.5 | RSA | 256 bytes at 2048 bits | Most widely supported |
PS256 | RSA-PSS | RSA | 256 bytes at 2048 bits | Better padding |
ES256 | ECDSA P-256 | EC | 64 bytes | Compact, needs good randomness or a deterministic nonce |
EdDSA | Ed25519 | Ed25519 | 64 bytes | Simple and hard to misuse |
none | None | None | Empty | Never accept |
The HS256 line needs emphasis. With a shared secret, anyone who can verify can also forge. It is fine when issuer and verifier are the same service. It is wrong when many APIs verify tokens, since compromise of any one lets it mint tokens for all.
A shared secret also has to be strong. HS256 tokens can be attacked offline, so a captured token can be fed to a cracking tool against a wordlist. A secret should be random and at least 256 bits.
Rough costs on one laptop, using openssl speed for the asymmetric algorithms:
| Operation | Per second |
|---|---|
| RSA-2048 sign | 2,457 |
| RSA-2048 verify | 94,116 |
| ECDSA P-256 sign | 81,993 |
| ECDSA P-256 verify | 27,576 |
Verification is fast enough that it is rarely the limit. RSA favours systems that verify far more than they sign, which fits tokens well.
Encrypted JWTs
A JWE has five parts instead of three:
header.encrypted_key.iv.ciphertext.tag
JWE keeps claims secret from the client and from logs. It is less common, because a simpler option exists: if the client should not read the claims, use an opaque token and put them on the server. When used, it is usually nested: signed first, then encrypted, so the claims are authenticated by the issuer.
Related formats
- PASETO removes algorithm choice from the token: a version implies a fixed suite, so there is no
algto attack. - SD-JWT, RFC 9901, lets a holder reveal chosen claims, which suits digital credentials.
- CBOR Web Tokens, RFC 8392, use a compact binary form for constrained devices.
- SAML assertions are XML predecessors, still common in enterprise single sign-on.
- Macaroons allow a holder to add restrictions to a token, without talking to the issuer.
How JWTs get broken
alg: none
The JWS standard defines an “unsecured” algorithm with an empty signature. In 2015, Tim McLean showed that several libraries honoured it, so stripping a signature produced a valid token. RFC 9068 forbids none for access tokens outright.
The naive form is short:
def naive(token, key):
head, body, tag = token.split(".")
if json.loads(unb64url(head))["alg"] == "none":
return json.loads(unb64url(body)) # accepted with no signature
Algorithm confusion
This is the most instructive JWT attack. A service expects RS256, and verifies with an RSA public key. The public key is, by definition, public. If the verifier reads alg from the token, an attacker can do this:
- Take the server’s public key PEM file.
- Set the header to
HS256. - Compute an HMAC over the token, using the public key’s bytes as the secret.
A library with one verify(token, key) function now runs HMAC with the same key bytes, gets the same result, and accepts the token. This script runs the attack against real RSA keys. Generate them first:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa.key
openssl pkey -in rsa.key -pubout -out rsa.pub
import base64, hashlib, hmac, json, os, subprocess, tempfile
def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def unb64url(t): return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
def enc(o): return b64url(json.dumps(o, separators=(",", ":")).encode())
def rsa_sign(msg):
return subprocess.run(["openssl", "dgst", "-sha256", "-sign", "rsa.key"],
input=msg, capture_output=True, check=True).stdout
def rsa_verify(msg, sig):
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(sig)
r = subprocess.run(["openssl", "dgst", "-sha256", "-verify", "rsa.pub", "-signature", f.name],
input=msg, capture_output=True)
os.unlink(f.name)
return r.returncode == 0
def bad_verify(token, key):
"""Trusts the alg in the header: the classic mistake."""
head, body, sig = token.split(".")
msg, sig = f"{head}.{body}".encode(), unb64url(sig)
alg = json.loads(unb64url(head))["alg"]
if alg == "RS256":
return rsa_verify(msg, sig)
if alg == "HS256":
return hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), sig)
return False
def good_verify(token):
"""The verifier decides the algorithm."""
head, body, sig = token.split(".")
if json.loads(unb64url(head))["alg"] != "RS256":
return False
return rsa_verify(f"{head}.{body}".encode(), unb64url(sig))
pem = open("rsa.pub", "rb").read()
msg = f'{enc({"alg": "RS256", "typ": "at+jwt"})}.{enc({"sub": "user-42"})}'
token = msg + "." + b64url(rsa_sign(msg.encode()))
print("legit: bad_verify", bad_verify(token, pem), "| good_verify", good_verify(token))
# The attacker has only the public key
msg = f'{enc({"alg": "HS256", "typ": "JWT"})}.{enc({"sub": "admin"})}'
forged = msg + "." + b64url(hmac.new(pem, msg.encode(), hashlib.sha256).digest())
print("forged: bad_verify", bad_verify(forged, pem), "| good_verify", good_verify(forged))
Output:
legit: bad_verify True | good_verify True
forged: bad_verify True | good_verify False
An attacker with no secret became admin. The fix is a one-line principle: the verifier decides the algorithm, the token does not. Good libraries require an explicit allow-list, and bind each key to one algorithm.
Key injection through headers
- Embedded
jwk. If a verifier trusts a key inside the token, an attacker signs with their own key and attaches it. CVE-2018-0114 in a Node library was this bug. jkuorx5u. If a verifier fetches a key set from a URL in the token, the attacker supplies the URL. It is also a server-side request forgery route.kidinjection.kidis a string the attacker controls. If it reaches a file path or a SQL query, it can cause path traversal or injection. For example, akidpointing to a file with known contents lets the attacker sign with that as an HMAC key.
Treat these headers as hostile. Look up kid in a fixed set of trusted keys, and take the key set URL from configuration.
Psychic signatures
CVE-2022-21449 was a bug in Java 15 to 18, in the rewritten ECDSA code. Verification divides by a value derived from s, and ECDSA requires r and s to be non-zero. The check was missing, so a signature of r = 0, s = 0 verified for any message under any key. It affected ES256 JWTs, SAML, and WebAuthn messages. Well-designed protocols can still fail in their library.
Signature exclusion and stripping
Some libraries have a decode function that does not verify, and a verify function that does. Code that calls the wrong one parses forged tokens quite happily. API gateway rules that check for a header’s presence but not its validity make the same mistake.
Weak secrets
If alg is HS256, a captured token allows offline guessing. Default secrets from tutorials, such as secret or the sample in a framework’s documentation, have turned up in production.
Confused audience and token reuse
A valid token used in the wrong place is the most common real failure:
- An ID token accepted by an API as an access token.
- A token for API A replayed to API B, because neither checks
aud. - A token from a multi-tenant issuer accepted from any tenant, because
issor a tenant claim was not validated. - A token type confused with another, because all use the same key. This is why RFC 8725 recommends explicit typing, and RFC 9068 defines
typ: at+jwt.
Stolen signing keys
If the signing key leaks, an attacker can create tokens for anyone, with no login. In the 2023 Storm-0558 intrusion, an actor used a stolen Microsoft consumer signing key to forge tokens that were accepted for enterprise mail accounts, partly because of a validation flaw that let a key from one system work in another. It is the worst case for self-contained tokens: nothing is compromised in any logged sense, because the forged tokens are cryptographically valid.
This is why signing keys belong in HSMs or cloud KMS, are rotated, and are separated by purpose.
JWE-specific attacks
Decompression bombs through the zip header, and invalid curve attacks on elliptic key agreement, are classes that apply to encrypted tokens. RFC 8725 covers them, and its successor, a draft known as 8725bis, adds attacks discovered since.
The verification checklist
A correct verifier does these in order:
- Check that the token has the expected structure and size.
- Check
algagainst an allow-list fixed by configuration. - Check
typwhere the token type is defined. - Select the key from a trusted set, by
kid. - Verify the signature.
- Check
issexactly. - Check
audcontains this service. - Check
exp,nbf, andiatwith a small leeway. - Check the scope or other permission for this action.
- Check the sender constraint, if there is a
cnfclaim. - Check revocation, if the risk needs it.
Leeway should be small, seconds rather than minutes. It exists for clock skew, and every second of it extends a token’s life.
Key distribution: JWKS
Verifiers need public keys. An issuer publishes them as a JSON Web Key Set, at a URL that is found through a discovery document.
curl https://accounts.google.com/.well-known/openid-configuration
{
"keys": [{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2026-10",
"n": "...",
"e": "AQAB"
}]
}
Rotation depends on overlap:
Day 0 Publish new key B alongside A. Keep signing with A.
Day 2 All verifier caches have refreshed. Start signing with B.
Day 3 The last tokens signed by A have expired.
Day 4 Remove A from the key set.
For an emergency, you skip the overlap, remove the key, and accept that some valid tokens fail.
Verifiers cache the key set. A sensible client refreshes on a schedule, and on an unknown kid, with a rate limit, since attackers can send random kid values to force fetches.
Access tokens, refresh tokens and ID tokens
These three are roles.
| Access token | Refresh token | ID token | |
|---|---|---|---|
| Audience | The API | The authorisation server | The client |
| Purpose | Call an API | Get new tokens | Tell the client who logged in |
| Lifetime | Minutes | Days to months | Minutes |
| Format | Opaque or JWT | Usually opaque | Always JWT |
| Client reads it? | Should not | Should not | Yes |
| Sent to APIs? | Yes | Never | Never |
An access token is opaque to the client. A client that parses one breaks when the issuer changes its format. The client’s job is to carry it to the API.
An ID token is never sent to an API. Its aud is the client, so a correct API rejects it.
A refresh token is sent only to the authorisation server, which is what makes short access token lifetimes bearable. Users stay logged in for weeks, while the token exposed to APIs lives for minutes.
Opaque or JWT access tokens?
| Consideration | Opaque | JWT |
|---|---|---|
| Latency | Needs introspection, or a cache | Local check |
| Issuer outage | APIs fail, unless cached | APIs continue |
| Instant revocation | Yes | Needs extra work |
| Claims visible to the client | No | Yes |
| Size | Tiny | Hundreds of bytes or more |
| Works across organisations | Needs issuer access | Needs only public keys |
A common hybrid is phantom tokens: the client holds an opaque token, and the API gateway exchanges it for a JWT, which is then passed inside the network. Clients see nothing, and internal services get signed claims.
Revocation strategies for JWTs
A JWT cannot be recalled, but there are several options, each with a cost.
| Strategy | Time to take effect | Cost |
|---|---|---|
| Short expiry | Up to the token lifetime | More refreshes |
Denylist by jti | Seconds | State at each verifier |
| Per-user “valid after” timestamp | Seconds | A lookup, but small state |
| Rotating the signing key | Immediate | Invalidates everyone |
| Introspection on sensitive routes | Immediate | A network call on those routes |
| Sender constraint | Prevents use of theft | Client key management |
| Shared Signals events | Seconds | Infrastructure |
A denylist reintroduces state, which leads to a fair question: if every API has to check a shared list, what did the signature buy? The answer is that the list holds only revoked tokens, not all live ones, and entries can be dropped when the token would have expired anyway.
The default many systems settle on is a 5 to 15 minute access token, a rotated refresh token, and introspection for high-risk actions.
OAuth 2.0
OAuth solves a specific problem. A photo printing site wants to read your photos from another service. Before OAuth, it would ask for your password. That gave it full access, forever, and the service could not tell it apart from you.
OAuth lets you grant a limited token instead.
The four roles
| Role | Example |
|---|---|
| Resource owner | You |
| Client | The printing site |
| Authorisation server (AS) | The login and consent service |
| Resource server (RS) | The photo API |
Authorization code flow with PKCE
This is the flow to use for nearly every case with a user.
Browser Client Auth server API
| | | |
| 1. click login | | |
|---------------->| | |
| 2. redirect | | |
|<----------------| | |
| 3. /authorize + code_challenge | |
|------------------------------------->| |
| 4. log in, MFA, consent | |
|<------------------------------------>| |
| 5. redirect with ?code=xyz | |
|<-------------------------------------| |
| 6. deliver code | |
|---------------->| | |
| | 7. POST /token | |
| | code + verifier | |
| |------------------->| |
| | 8. access, refresh | |
| |<-------------------| |
| | 9. API call | |
| |------------------------------------>|
The authorisation request:
GET /authorize?response_type=code
&client_id=print-app
&redirect_uri=https%3A%2F%2Fprint.example%2Fcb
&scope=photos.read
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: id.example
The code exchange happens over a direct back channel:
POST /token HTTP/1.1
Host: id.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fprint.example%2Fcb
&client_id=print-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "8xLOxBtZp8",
"scope": "photos.read"
}
Tokens never appear in the browser’s URL. The front channel carries only a short-lived, one-time code. It is delivered through a redirect, which can leak through logs, history, and referrers, so the code alone must be useless.
Why PKCE exists
A mobile app cannot keep a client secret, since anyone can extract it from the binary. So if another app on the device intercepts the code, it could exchange the code for tokens. PKCE, RFC 7636, closes that gap.
The client makes a random code_verifier, and sends its hash as the code_challenge. Later it reveals the verifier. An interceptor has the code, and has seen the challenge, but it cannot reverse the hash.
import base64, hashlib
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=")
print(challenge.decode())
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
That matches the worked example in RFC 7636’s Appendix B.
RFC 9700, the OAuth security best current practice from January 2025, says that authorisation servers must support PKCE and that public clients must use it, and its advice extends to all client types, since PKCE also stops code injection. S256 is the only acceptable method.
state and nonce
state stops CSRF on the redirect, by tying the response to the browser session that started it. A random value goes in a cookie or session, and is compared on return. PKCE provides similar protection, which RFC 9700 recognises, but state still carries application data.
nonce, in OpenID Connect, binds an ID token to the request, and stops replay of the ID token.
The grant types
| Grant | Use | Status |
|---|---|---|
| Authorization code with PKCE | Any user-facing client | Use this |
| Client credentials | Service with no user | Use this |
| Refresh token | Renewing access | Use this, with protection |
| Device authorization, RFC 8628 | TVs and CLIs | Use this for input-limited devices |
| Token exchange, RFC 8693 | Service acting for a user | Use this for delegation |
| JWT bearer, RFC 7523 | Signed assertion as a grant | Use this for federation |
| Implicit | Old browser apps | Should not be used |
| Password | Legacy | Must not be used |
Implicit returned tokens directly in the redirect fragment. It existed because browsers could not make cross-origin POSTs at the time. CORS and PKCE removed that need, and the token in the URL is exposed. RFC 9700 says it should not be used, and OAuth 2.1 removes it.
Password gives the client the user’s password, which defeats the purpose of OAuth, and cannot work with MFA or passkeys. RFC 9700 says it must not be used.
Client credentials
POST /token HTTP/1.1
Authorization: Basic cHJpbnQtam9iOnNlY3JldA==
grant_type=client_credentials&scope=reports.write
No user is involved, so the token represents the service itself. A shared secret is the weakest way to authenticate. A better one is private_key_jwt, where the client signs a short-lived JWT, RFC 7523. mTLS, RFC 8705, is another. In both, no secret crosses the network.
Device authorization
For a TV or a CLI, the device shows a code, and the user approves it on a phone.
Device -> AS: POST /device_authorization
AS -> Device: device_code, user_code=WDJB-MJHT, verification_uri
Device shows: Visit https://id.example/device and enter WDJB-MJHT
Device polls: POST /token with device_code, until approved
It is exposed to phishing, since an attacker can start a flow, and persuade a victim to enter the code on the real site. The victim logs in to the real provider, and the attacker’s device gets the tokens. So it should be disabled where it is not needed.
Refresh token rotation
A refresh token is valuable and long-lived. RFC 9700 says that for public clients, it must be sender-constrained or rotated.
With rotation, each use returns a new token, and the old one becomes invalid. All tokens from one login are a family. If an already-used token appears, the server cannot tell who is the thief, so it revokes the family.
import secrets
class AuthServer:
def __init__(self):
self.families, self.tokens = {}, {}
def issue(self):
family = secrets.token_hex(4)
self.families[family] = True
return self._new(family)
def _new(self, family):
token = secrets.token_hex(8)
self.tokens[token] = {"family": family, "used": False}
return token
def refresh(self, token):
record = self.tokens.get(token)
if record is None or not self.families[record["family"]]:
return "invalid_grant"
if record["used"]:
self.families[record["family"]] = False
return "invalid_grant, reuse detected, family revoked"
record["used"] = True
return self._new(record["family"])
auth = AuthServer()
first = auth.issue()
stolen = first
second = auth.refresh(first)
print("client rotates:", second != first)
print("attacker replays:", auth.refresh(stolen))
print("client next refresh:", auth.refresh(second))
client rotates: True
attacker replays: invalid_grant, reuse detected, family revoked
client next refresh: invalid_grant
The legitimate client is logged out too, which is the price of safety. If the attacker uses the stolen token first, the same thing happens in the other order: the real client’s next refresh triggers detection.
Rotation creates a practical race. A mobile app that loses the response on a bad network retries with the old token, and looks like an attacker. Many servers allow a brief grace period for this.
Scopes and their limits
A scope such as photos.read limits what a client may do on a user’s behalf. The user’s own permissions still apply. Effective access is the intersection:
Client's scope ∩ User's permissions ∩ Resource policy = allowed
Scopes are coarse. “Pay 50 euros to this account” does not fit in a string. Rich Authorization Requests, RFC 9396, add structure:
{
"authorization_details": [{
"type": "payment_initiation",
"instructedAmount": { "currency": "EUR", "amount": "50.00" },
"creditorAccount": { "iban": "DE02..." }
}]
}
Scopes also say nothing about which object. An API that checks orders:read but not whether this order belongs to this user has a broken object-level authorisation flaw, which tops the OWASP API risks. A token gets you to the door, and the object check happens in the application.
Fine-grained authorisation
Tokens are a poor place for detailed permissions, since these change often, and tokens are large and immutable. Patterns to use instead:
| Model | Decision based on | Example |
|---|---|---|
| RBAC | Roles | admin |
| ABAC | Attributes of user, resource, context | Department and clearance |
| ReBAC | Relationships in a graph | Editor of a folder containing this file |
| Policy as code | Rules evaluated by an engine | OPA, Cedar |
Google’s Zanzibar paper set out the relationship model, and the OpenID AuthZEN work defines a standard API between enforcement and decision points.
Token exchange and delegation
A frontend API receives a user’s token, and needs to call a backend. Forwarding the token is a poor idea: its aud is wrong, and the backend now has the power to call anything else the token allows. RFC 8693 instead lets a service exchange it for a new token.
POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ...
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=api://inventory
&scope=stock.read
The result has a narrower audience and scope, and can record the chain:
{
"iss": "https://id.example",
"aud": "api://inventory",
"sub": "user-42",
"scope": "stock.read",
"act": { "sub": "service:orders" }
}
The act claim distinguishes delegation, where a service acts for a user and both are visible, from impersonation, where the service simply becomes the user. Delegation is better for audit.
The confused deputy is what this prevents. A service with broad rights can be tricked into using those rights for a caller who lacks them. Passing the user’s identity down the chain means the final decision considers the user.
Other parts of the OAuth family
| RFC | Name | Purpose |
|---|---|---|
| 6749 | OAuth 2.0 | The framework |
| 6750 | Bearer usage | How to send tokens |
| 7009 | Revocation | Client revokes a token |
| 7521, 7523 | Assertions | JWT as a grant or client authentication |
| 7591 | Dynamic registration | Clients register themselves |
| 7636 | PKCE | Code protection |
| 7662 | Introspection | RS asks whether a token is active |
| 8252 | Native apps | Use the system browser, and loopback or claimed URLs |
| 8414 | AS metadata | Discovery |
| 8628 | Device grant | Input-limited devices |
| 8693 | Token exchange | Delegation |
| 8705 | mTLS | Client auth and bound tokens |
| 8707 | Resource indicators | Name the target API |
| 9068 | JWT access tokens | A standard profile |
| 9101 | JAR | Signed authorisation requests |
| 9126 | PAR | Push request parameters over a back channel |
| 9207 | iss parameter | Defends against mix-up attacks |
| 9396 | RAR | Structured permissions |
| 9449 | DPoP | Proof of possession |
| 9470 | Step-up | Ask for stronger authentication |
| 9635 | GNAP | A separate successor protocol |
| 9700 | Security BCP | Current best practice |
| 9728 | Protected resource metadata | An API describes its AS |
PAR is worth a note. Instead of carrying parameters through the browser, the client POSTs them to the server, and gets a request_uri to redirect with. That hides them from the browser, and authenticates them before the user arrives.
OAuth 2.1 consolidates the current best practice in one document: PKCE everywhere, no implicit or password grants, exact redirect URI matching, and no bearer tokens in query strings. As of draft 16, in September 2026, it is still an Internet-Draft.
FAPI 2.0 is a stricter OpenID Foundation profile, built for financial APIs. It requires PAR, PKCE, and sender-constrained tokens.
OAuth attacks
Open redirectors and loose redirect matching. If an AS allows a wildcard, the code can go to an attacker’s page. RFC 9700 requires exact string matching, with one exception for localhost ports in native apps.
Code injection. An attacker places a stolen code into their own session. PKCE stops this.
Mix-up. A client using several authorisation servers is tricked into sending a code to the wrong one. RFC 9207’s iss parameter defends against it.
Consent phishing. A malicious app asks for permission through the real provider’s real consent page. Everything is genuine, except the app. No credentials are stolen, MFA does not help, and the access persists after a password change.
Third-party token theft. In the 2025 Salesloft Drift incident, attackers used stolen OAuth tokens from an integration to pull data from customers’ Salesforce instances. A connected app’s long-lived token is a credential that security teams often do not watch.
Token leakage. Tokens in query strings go to logs, history, and referrers. RFC 9700 says access tokens must not be sent in a query parameter.
Scope escalation. A client requests more at refresh, or a server fails to enforce the original consent.
OpenID Connect
OAuth says what a client may do. It does not say who the user is. An access token proves that some API access was granted, not that a user is present at the client.
OpenID Connect adds identity to the code flow. When scope includes openid, the token response also contains an ID token:
{
"iss": "https://id.example",
"sub": "248289761001",
"aud": "print-app",
"exp": 1793500300,
"iat": 1793500000,
"auth_time": 1793499950,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "otp"]
}
The client validates it like any other JWT, including its own aud and the nonce. The client then normally creates its own session. This confuses people, because there are now several sessions:
1. The identity provider's session (SSO)
2. Your application's session (your cookie)
3. Access and refresh tokens (for APIs)
Each has its own lifetime. Logging out of one does not end the others. OIDC defines RP-initiated, front-channel, and back-channel logout. Back-channel logout, where the provider POSTs a signed logout token to your server, is the reliable one, as browsers increasingly block third-party cookies in iframes.
SSO works because of the first of these: a second application redirects to the provider, which sees its own cookie, and signs the user in with no prompt.
Tokens in the browser
Where a single-page app keeps its tokens is among the most argued questions in web security.
| Storage | XSS can read it | CSRF applies | Survives reload |
|---|---|---|---|
localStorage | Yes | No | Yes |
sessionStorage | Yes | No | Tab only |
| JS memory | Harder, not impossible | No | No |
HttpOnly cookie | No, but can use it | Yes | Yes |
| Server-side, via BFF | No | Yes, at the BFF | Yes |
The honest position is that with XSS, an attacker running in your origin can already make requests as the user. HttpOnly stops the token leaving the browser, and that matters, because a stolen token works later, from anywhere. Nothing in the browser stops abuse during the attack.
The IETF draft on OAuth for browser-based apps ranks three patterns:
- Backend for Frontend. A small server acts as a confidential OAuth client, and holds all tokens. The browser gets only an
HttpOnlysession cookie. This is the strongest option. - Token-mediating backend. The backend runs the flow, then gives the access token to the browser.
- Browser-based client. The browser runs the flow itself. It has the most exposure.
Browser <-- cookie --> BFF <-- access token --> API
|
+-- refresh token stays here
Notice the circle: the most secure way to use tokens in a browser is a session cookie. Tokens are excellent between services, and for APIs called by many clients. A traditional web app with one backend may have no need of them at the browser.
“Should I use JWT for sessions?”
Often not. The common pattern of putting a JWT in a cookie, as the session, gives up a lot:
| Claimed benefit | Reality |
|---|---|
| Stateless, so scalable | Most apps hit a database on each request anyway |
| No session store | Revocation brings one back |
| Works across services | Only if audience and keys are managed well |
| Smaller | It is much bigger than an ID |
| More secure | It is not, and logout becomes weak |
If an application has one backend and its own users, a server-side session in Postgres, with a cache in front if needed, is simpler and stronger. JWTs earn their place when independent services must verify identity without a shared store.
Sender-constrained tokens
A bearer token works for any holder. Sender constraints bind a token to a key, so a stolen token alone is useless.
Mutual TLS
RFC 8705 binds the token to the client certificate from the TLS handshake. The token holds a hash of it:
{ "cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" } }
The resource server compares that hash with the certificate on the connection. This works well between services. It works poorly in browsers, and TLS termination at a proxy needs the certificate to be forwarded safely.
DPoP
RFC 9449 works at the application layer. The client creates a key pair, and signs a short proof for each request. This program builds a real proof with ES256. Generate a key first:
openssl ecparam -name prime256v1 -genkey -noout -out ec.key
openssl pkey -in ec.key -pubout -out ec.pub
import base64, hashlib, json, subprocess
def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
def enc(o): return b64url(json.dumps(o, separators=(",", ":")).encode())
def der_to_raw(der):
"""OpenSSL returns DER. JOSE wants r and s as 32 raw bytes each."""
rlen = der[3]
r = der[4:4 + rlen]
s = der[4 + rlen + 2:]
return r.rjust(33, b"\0")[-32:] + s.rjust(33, b"\0")[-32:]
public = subprocess.run(["openssl", "pkey", "-pubin", "-in", "ec.pub", "-outform", "DER"],
capture_output=True).stdout[-64:]
jwk = {"crv": "P-256", "kty": "EC", "x": b64url(public[:32]), "y": b64url(public[32:])}
access_token = "2YotnFZFEjr1zCsicMWpAA"
header = enc({"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk})
claims = enc({"jti": "e1j3V_bKic8-LAEB", "htm": "GET",
"htu": "https://api.example/orders", "iat": 1793500000,
"ath": b64url(hashlib.sha256(access_token.encode()).digest())})
message = f"{header}.{claims}".encode()
der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", "ec.key"],
input=message, capture_output=True, check=True).stdout
raw = der_to_raw(der)
print("proof:", len(message) + 1 + len(b64url(raw)), "bytes,", len(raw), "byte signature")
thumbprint = b64url(hashlib.sha256(json.dumps(jwk, separators=(",", ":"), sort_keys=True).encode()).digest())
print("token claim:", json.dumps({"cnf": {"jkt": "..."}}), "with jkt of length", len(thumbprint))
proof: 498 bytes, 64 byte signature
token claim: {"cnf": {"jkt": "..."}} with jkt of length 43
Each part defends against something:
| Field | Defends against |
|---|---|
jwk | Tells the server which key, checked against the token’s cnf.jkt |
htm, htu | Reuse of the proof on another method or URL |
iat, jti | Replay of the proof later |
ath | Use of the proof with another access token |
Server nonce | Pre-generated proofs |
The request carries both:
GET /orders HTTP/1.1
Host: api.example
Authorization: DPoP 2YotnFZFEjr1zCsicMWpAA
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...
DPoP works in browsers, with WebCrypto keys marked non-extractable. So XSS can use the key while it runs, but cannot take the key away. RFC 9449 is clear on the limit: an attacker running code in the client’s context defeats it.
Device-bound sessions
The same idea reaches cookies. Device Bound Session Credentials, a Chrome-led proposal, ties a session to a key in the device’s secure hardware, so a stolen cookie fails on another machine.
Workload identity
Half of the callers in a modern system are not people. A service with a long-lived API key in an environment variable is the machine version of a password on a sticky note.
The modern pattern has no stored secret. The platform attests what a workload is, and issues a short-lived identity.
SPIFFE and SPIRE
SPIFFE is a standard for naming workloads:
spiffe://example.org/ns/payments/sa/api
A workload obtains an SVID, its identity document, from a local Workload API. SPIRE is the main implementation, and it verifies the workload through attestation, such as its Kubernetes service account or its node. There are two forms:
- X.509-SVID, a certificate used for mTLS. It proves possession, so it is preferred.
- JWT-SVID, a signed token, for when mTLS is impractical. It is a bearer token.
Both are short-lived and rotated automatically.
Kubernetes service account tokens
Kubernetes projects a short-lived, audience-bound JWT into each pod:
{
"aud": ["https://kubernetes.default.svc"],
"exp": 1793503600,
"iss": "https://kubernetes.default.svc.cluster.local",
"kubernetes.io": {
"namespace": "payments",
"serviceaccount": { "name": "api" }
},
"sub": "system:serviceaccount:payments:api"
}
kubectl create token api --audience=vault --duration=10m
Since the cluster publishes an OIDC discovery document, external systems can verify those tokens.
Federation with no stored secrets
Cloud workload identity federation builds on that. A CI job gets an OIDC token from its platform, and exchanges it for short-lived cloud credentials. No cloud key is ever stored.
GitHub Actions job -> OIDC token (sub: repo:acme/app:ref:refs/heads/main)
-> AWS STS AssumeRoleWithWebIdentity
-> temporary credentials, valid for that job
The security rests on the trust policy. A condition that checks only the issuer, not the repository and branch, would let any repository on that platform assume the role. That is exactly the iss and aud validation discussed above, in another form.
Zero Trust
Traditional networks are a castle: a hard perimeter, and trust inside it. Once an attacker gets in through a phished VPN login, they can move laterally. With cloud services, remote work, and third parties, the perimeter no longer describes anything real.
The term “zero trust” comes from Forrester’s John Kindervag around 2010. Google’s BeyondCorp, published from 2014, showed it working at scale: employees reached internal apps from untrusted networks, with no VPN, through an access proxy that checked user and device.
NIST SP 800-207
NIST’s 2020 publication is the reference definition. It has seven tenets:
- All data sources and computing services are resources.
- All communication is secured, regardless of network location.
- Access to individual resources is granted per session.
- Access is determined by dynamic policy, including client identity, application, and observable state.
- The enterprise monitors the integrity and security posture of all owned and associated assets.
- Authentication and authorisation are dynamic, and strictly enforced before access is allowed.
- The enterprise collects as much information as possible about the state of assets, network, and communications, and uses it to improve its security posture.
The core sentence is simple: being on the network earns no trust.
The logical architecture
NIST separates decision from enforcement.
Control plane
+----------------------------------+
| Policy Engine (decides) |
| Policy Administrator (executes) |
+-----^--------------------|-------+
| |
inputs: identity, device, | allow, deny, revoke
threat intel, logs, policy |
| v
Subject ---> Policy Enforcement Point ---> Resource
Data plane
- The Policy Engine makes the decision.
- The Policy Administrator carries it out, and can tear down a session.
- Together, they form the Policy Decision Point.
- The Policy Enforcement Point sits in the path, and enables, monitors, and ends connections.
Mapping tokens to Zero Trust
OAuth was not designed as Zero Trust, but it fits this structure closely.
| Zero Trust concept | OAuth or token equivalent |
|---|---|
| Policy Decision Point | Authorisation server, policy engine |
| Policy Enforcement Point | API gateway, service mesh proxy, resource server |
| Per-session access | Short-lived, audience-restricted tokens |
| Least privilege | Scopes, RAR, token exchange |
| Strong identity | OIDC with phishing-resistant MFA |
| Device posture | Conditional access claims, mTLS device certs |
| Dynamic policy | Evaluation at token issue, and at the PEP |
| Continuous evaluation | Short expiry, Shared Signals, CAE |
| Encrypted communication | TLS, mTLS everywhere |
| Workload identity | SPIFFE, federation |
| Monitoring | Token and access logs |
A key point: a JWT is a cached policy decision. The authorisation server evaluated policy at time T, and the token carries that verdict until exp. This is the deep conflict between tokens and Zero Trust. Tenet 4 wants decisions based on current state, and a token says what was true when it was issued.
Reducing that gap gives the range of designs:
| Design | Decision age | Dependency on the PDP |
|---|---|---|
| 24-hour bearer token | Up to a day | None |
| 5-minute token | Up to 5 minutes | Low |
| Token plus Shared Signals | Seconds | Event stream |
| Introspection | Zero | Every request |
| PEP asks PDP each time | Zero | Every request |
There is no universally right row. The higher the risk of the action, the fresher the decision should be. A read of a public catalogue tolerates a five-minute-old decision. A wire transfer does not.
Step-up authentication
RFC 9470 standardises the pattern of asking for more on demand. An API rejects a token that is not strong enough:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="A more recent login is required",
acr_values="urn:example:mfa", max_age=300
The client sends the user back through the flow with those requirements. The API checks acr and auth_time.
Continuous access evaluation
If tokens are cached decisions, the issuer needs a way to say they have gone stale. The OpenID Shared Signals Framework defines how systems send each other security events, as signed JWTs. CAEP defines session-related events, and RISC defines account risk events. Those three became Final Specifications in 2025.
{
"iss": "https://idp.example",
"aud": "https://api.example",
"events": {
"https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
"subject": { "format": "iss_sub", "iss": "https://idp.example", "sub": "user-42" },
"event_timestamp": 1793500100
}
}
}
Microsoft’s Continuous Access Evaluation is a large deployed example. Resource services subscribe to critical events, such as an account being disabled, a password change, or a network location change. That lets tokens live longer, with an event able to end them in near real time. It solves the staleness and the outage dependency together, but the resource must understand the signals.
Service mesh and east-west traffic
Inside a cluster, Zero Trust means services authenticate each other, regardless of network position.
A mesh such as Istio or Linkerd, or Cilium’s mutual authentication, provides workload identity and mTLS with no application changes. The proxy is the PEP. Layered on this, the application still verifies the user’s token, since mTLS proves which service called, not on whose behalf.
Service A -> [proxy] == mTLS, SPIFFE ID ==> [proxy] -> Service B
Authorization: Bearer <user token, audience B>
Both identities matter. One says “this is the orders service”. The other says “acting for user 42”.
A policy engine ties these together, for example with OPA:
package httpapi.authz
default allow := false
allow if {
input.source_spiffe_id == "spiffe://example.org/ns/shop/sa/orders"
input.method == "GET"
input.path[0] == "stock"
input.token.aud == "api://inventory"
"stock.read" in split(input.token.scope, " ")
}
The admission policy from the Kubernetes article is the same idea, at a different point in the system.
Maturity
CISA’s Zero Trust Maturity Model 2.0, from April 2023, has five pillars: identity, devices, networks, applications and workloads, and data. Three capabilities cut across them: visibility and analytics, automation and orchestration, and governance. Each moves through four stages: traditional, initial, advanced, and optimal.
What Zero Trust does not do
- It is not a product, whatever the marketing says.
- It does not remove the need for network segmentation. It adds to it.
- It makes the identity provider the centre of everything, so that provider becomes a critical dependency and target.
- It does not stop a legitimate user from abusing legitimate access. Monitoring has to address that.
- It does not fix application bugs. A correct token still reaches vulnerable code.
Choosing a design
| Situation | Reasonable design |
|---|---|
| Web app with one backend | Server-side session, in a secure cookie |
| SPA with your own API | BFF with a session cookie |
| Mobile app | Code flow with PKCE, system browser, rotated refresh token in secure storage |
| Third-party API access | OAuth code flow, narrow scopes, consent |
| Service to service, same organisation | mTLS with SPIFFE, or client credentials with private_key_jwt |
| Microservices with user context | Gateway validates, token exchange per audience |
| CLI tool | Device flow or loopback redirect |
| CI to cloud | OIDC federation, with no stored keys |
| Financial or high-risk API | FAPI 2.0, DPoP or mTLS |
| Internal apps, no VPN | Identity-aware proxy, with device posture |
Suggested lifetimes
These are common starting points, not standards. Adjust them to the risk.
| Token | Lifetime |
|---|---|
| Authorisation code | Under 1 minute, one use |
| Access token | 5 to 15 minutes |
| Sensitive access token | Under 5 minutes, or sender-constrained |
| Refresh token | Days to weeks, rotated |
| ID token | Minutes, used once |
| DPoP proof | Seconds |
| Web session | Idle 30 to 60 minutes, absolute 12 to 24 hours |
| Workload JWT | Minutes |
| Signing key | Rotated every few months |
The checklist
Issuing
- Use an established authorisation server, rather than writing your own.
- Sign with an asymmetric key, held in an HSM or KMS.
- Set
iss,aud,exp,iat,jti, andtyp. - Use short lifetimes, and put no secrets in claims.
- Use one audience per token, and narrow scopes.
- Separate keys by purpose and environment.
Verifying
- Pin the algorithm, and bind each key to one.
- Validate
iss,aud,exp, andtyp. - Ignore
jku,x5u, and embeddedjwk. - Use a maintained library, and keep it patched.
- Authorise at the object level in application code.
OAuth
- Use code flow with PKCE.
- Do not use implicit or password grants.
- Match redirect URIs exactly.
- Rotate or sender-constrain refresh tokens.
- Do not send tokens in URLs.
- Monitor and review third-party grants.
Sessions
- Use 256 bits of CSPRNG output, and store only a hash.
- Use
__Host-,Secure,HttpOnly, andSameSite. - Issue a new ID at login and at privilege changes.
- Enforce both idle and absolute timeouts.
- Make logout end the server-side session.
Operations
- Never log tokens, cookies, or
Authorizationheaders. - Scrub HAR files before sharing them.
- Rehearse signing key rotation before you need it.
- Alert on refresh token reuse.
- Log
jti,sub, andclient_id, so an incident can be traced.
What to take away
- A JWT is a format, an access token is a role, and OAuth is delegation.
- A stored lookup gives control, and a signed token gives independence.
- The verifier chooses the algorithm, and checks the audience. Most token bugs are one of those two.
- Sessions in secure cookies remain the best answer for many web applications.
- Use the code flow with PKCE, and exchange tokens rather than forwarding them.
- Bearer tokens are cash, and DPoP and mTLS add proof of possession.
- A token is a cached policy decision, and its lifetime is how stale you allow that decision to be.
- Zero Trust moves trust from the network to identity, and identity needs the same care as any critical system.