← Back to JWT Authentication for Spatial Scopes
This page covers the ordering that makes key rotation invisible to users: publish, wait, switch, wait again, retire — and why doing any two of those steps in the wrong order logs everybody out.
Context & When to Use
Signing keys need rotating on a schedule, after a suspected exposure, and whenever someone with access to them leaves. The mechanics are simple; the sequencing is where outages come from. Swap the key in one deployment and every token already in the wild becomes unverifiable, which for a spatial API means every dispatcher, every field device and every partner integration receives 401 simultaneously.
The safe version rests on two properties of JWT verification. The kid header names which key signed a token, and a verifier can hold several keys at once. That means signing and verification can be decoupled in time: verify against a set, sign with exactly one member of it, and move the signing member forward while the set still contains the old one.
Rotation interacts with token lifetime in a way that surprises people. The overlap must outlast the longest-lived token signed with the outgoing key. For an API whose tokens carry spatial scope claims — expensive to compute, therefore often long-lived — that can be weeks rather than hours.
Runnable Implementation
import time
from dataclasses import dataclass
from typing import Annotated, Any
import jwt
from fastapi import Depends, HTTPException, Header
from jwt import PyJWKClient
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
ALGORITHMS = ["RS256"] # explicit allow-list; never read alg from the token
ISSUER = "https://auth.example.com/"
AUDIENCE = "geospatial-api"
# Cache the key set, but not forever: a rotation must be picked up automatically
_jwks = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300, max_cached_keys=8)
@dataclass(frozen=True)
class Principal:
subject: str
tenant: str
scope_wkt: str | None
key_id: str
def verify(authorization: Annotated[str, Header()]) -> Principal:
if not authorization.startswith("Bearer "):
raise HTTPException(401, detail={"error": "missing_bearer_token"})
token = authorization.removeprefix("Bearer ")
try:
# The kid header selects the key; the client holds ALL published keys
signing_key = _jwks.get_signing_key_from_jwt(token)
claims: dict[str, Any] = jwt.decode(
token,
signing_key.key,
algorithms=ALGORITHMS, # pinned: alg=none can never verify
issuer=ISSUER,
audience=AUDIENCE,
options={"require": ["exp", "iat", "sub"]},
)
except jwt.PyJWKClientError:
# Unknown kid: the key was retired too early, or the JWKS is stale
raise HTTPException(401, detail={"error": "unknown_signing_key"})
except jwt.ExpiredSignatureError:
raise HTTPException(401, detail={"error": "token_expired"})
except jwt.InvalidTokenError as exc:
raise HTTPException(401, detail={"error": "invalid_token", "reason": str(exc)})
return Principal(
subject=claims["sub"],
tenant=claims.get("tenant", ""),
scope_wkt=claims.get("scope_geom"),
key_id=jwt.get_unverified_header(token)["kid"],
)On the issuing side, the only change during rotation is which key the signer selects:
SIGNING_KEYS = { # loaded from the secret store
"2026-02-key": {"private": ..., "not_after": 1793000000},
"2026-08-key": {"private": ..., "not_after": 1808000000},
}
ACTIVE_KID = "2026-08-key" # the ONLY thing a rotation changes
def issue(claims: dict[str, Any]) -> str:
key = SIGNING_KEYS[ACTIVE_KID]
return jwt.encode(claims, key["private"], algorithm="RS256",
headers={"kid": ACTIVE_KID})Key Parameters & Options
| Setting | Recommended | Why |
|---|---|---|
kid header | always set | Without it a verifier must try every key, and retirement is guesswork |
| JWKS cache lifespan | 300 s | Short enough to pick up a rotation, long enough to avoid hammering the endpoint |
algorithms= | explicit list | Never infer from the token; this is what blocks alg=none |
| Access token TTL | ≤ 1 h | Directly sets the minimum overlap |
| Refresh token key | separate kid | Stops a 30-day refresh token forcing a 30-day overlap on the access key |
| Retired keys kept | 1 previous | More than one usually means a rotation was never finished |
Watching a rotation land
A rotation is not finished when the deployment completes; it is finished when no traffic uses the old key. That is observable, and watching it is what tells you when retirement is safe.
Emergency rotation is a different procedure
Everything above assumes a planned rotation, where the old key is trusted right up to retirement. A suspected key compromise inverts the requirement: the old key must stop being trusted now, and the cost is that every token signed with it becomes invalid immediately.
That trade cannot be avoided, only prepared for. Short access tokens make the blast radius small — with a fifteen-minute TTL, an emergency revocation inconveniences at most fifteen minutes of sessions, and clients holding refresh tokens signed with a different key recover automatically. With a twelve-hour TTL, the same revocation is an outage for every user.
Gotchas & Failure Modes
- Switching the signer in the same deploy that publishes the key. Verifiers with a warm JWKS cache reject every new token until their cache expires. Two deploys, separated by at least the cache lifetime.
- Refresh tokens sharing the access key. A 30-day refresh token signed with the access key extends the required overlap to 30 days. Give refresh tokens their own
kid. - JWKS served without cache headers. Either every verification fetches it, or a CDN caches it for a day and rotation stalls. Set an explicit, modest
max-age. - No
kidin the header. Verification still works by trying keys, but you lose the per-key metric that tells you when retirement is safe, and the failure mode on retirement becomes untestable. - Retiring on a calendar date. The last straggler is always a long-lived integration token nobody remembered. Retire on the metric reaching zero, not on the date in the ticket.
- Rotation without an audit record. The key change is a security event. Record who rotated, when, and why, alongside the other controls in Audit Logging for Location Data Access.
Rehearsing matters because the emergency path exercises code that the planned path never touches: the unknown-key branch of the verifier, the client’s reaction to a sudden 401, and whatever automation is meant to notice. Run it against staging once a quarter, with the same people who would run it for real, and time how long the whole sequence takes from decision to fully-rotated. That number is the actual exposure window, and it is invariably longer than anyone estimates.
Verification Snippet
# The JWKS must contain BOTH keys during the overlap
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[] | {kid, alg, use}'
# {"kid":"2026-02-key","alg":"RS256","use":"sig"}
# {"kid":"2026-08-key","alg":"RS256","use":"sig"}
# A freshly issued token must name the new key
curl -s -X POST https://auth.example.com/token -d @creds.json \
| jq -r .access_token | cut -d. -f1 | base64 -d 2>/dev/null | jq .kid
# "2026-08-key"def test_tokens_from_both_keys_verify_during_overlap(old_token, new_token):
for token in (old_token, new_token):
principal = verify(f"Bearer {token}")
assert principal.subject
assert verify(f"Bearer {old_token}").key_id == "2026-02-key"
assert verify(f"Bearer {new_token}").key_id == "2026-08-key"
def test_unknown_kid_is_401_not_500(token_signed_with_retired_key):
with pytest.raises(HTTPException) as exc:
verify(f"Bearer {token_signed_with_retired_key}")
assert exc.value.status_code == 401
assert exc.value.detail["error"] == "unknown_signing_key"Related
- JWT Authentication for Spatial Scopes — the token model being rotated
- Validating Spatial Scope Claims in FastAPI Dependencies — what happens after verification succeeds
- Audit Logging for Location Data Access — recording the rotation as the security event it is
← Back to JWT Authentication for Spatial Scopes