Rotating JWT Signing Keys Without Dropping Sessions

Publish a JWKS with overlapping keys, sign with the newest and verify against all valid ones, and retire an old key only after the last token signed with it has expired.

← 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})
The five phases of a safe rotationA timeline across five phases. In phase one only the old key exists and is both published and signing. In phase two the new key is published but not yet signing, and the system waits at least one JWKS cache lifetime. In phase three signing switches to the new key while both remain published; this overlap must outlast the longest token signed with the old key. In phase four the old key is still published but no token signed with it can still be valid. In phase five the old key is removed from the JWKS. Two failure arrows mark what happens if the switch precedes publication or the retirement precedes token expiry.Publish → wait → switch → wait → retireold key publishedremoved at phase 5old key signingnew key publishednew key signingpublishswitchretire≥ 1 JWKS cache lifetime≥ longest token lifetime signed with the old keyswitch before publish→ mass 401sretire too early→ mass 401s

Key Parameters & Options

SettingRecommendedWhy
kid headeralways setWithout it a verifier must try every key, and retirement is guesswork
JWKS cache lifespan300 sShort enough to pick up a rotation, long enough to avoid hammering the endpoint
algorithms=explicit listNever infer from the token; this is what blocks alg=none
Access token TTL≤ 1 hDirectly sets the minimum overlap
Refresh token keyseparate kidStops a 30-day refresh token forcing a 30-day overlap on the access key
Retired keys kept1 previousMore 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.

Verifications by key id after the switchTwo series over seven days following the signing switch. Verifications using the new key rise sharply from zero to nearly all traffic within the first day. Verifications using the old key decay as existing tokens expire, dropping below one percent by day two and reaching zero on day six, when the last long-lived token issued before the switch expires. A marker on day seven shows the safe retirement point, one day after the last observed use.Verifications per key id — the retirement signal100 %02026-08-key (new)2026-02-key (old)last use: day 6retire hereone day afterd0d3d7Count verifications by kid. Retiring on a calendar date is how a straggler integrationwith a long-lived token gets locked out.

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.

Planned versus emergency rotationTwo procedures compared. A planned rotation publishes the new key, waits a cache lifetime, switches signing, waits for the longest token to expire and then retires the old key, with zero user impact and an exposure window equal to the overlap. An emergency rotation publishes the new key, switches signing and removes the old key from the JWKS immediately, ending exposure at once but invalidating every outstanding token. A note records that short access token lifetimes are what make the emergency path survivable.Two procedures, opposite prioritiesplannedpublish → wait → switch → wait → retireuser impact:noneold key trusted for:the full overlapthe default; schedule it quarterlyemergencypublish → switch → remove old key at onceuser impact:every token invalidatedold key trusted for:zerosurvivable only with short token lifetimesAccess token TTL sets the cost of the emergency path: 15 min → a blip; 12 h → an outage.Rehearse it in staging. The first time this procedure runs should not be the day it is needed.

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 kid in 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"

← Back to JWT Authentication for Spatial Scopes