How AuthPI publishes and rotates JWT signing keys: the JWKS endpoint, monthly rotation cadence, the 45-day overlap guarantee, the enforced 21-day token-lifetime cap, caching headers, and unknown-kid handling.
Last updated 2026-06-13
AuthPI signs every token — access, ID, and refresh — with asymmetric keys whose public halves are published as a JSON Web Key Set (RFC 7517). Your services verify tokens against this set and never need a shared secret. This page is the operational contract: where the keys live, when they rotate, how long old keys remain valid, and what your verifier must do about it.
Always take the URL from the discovery document rather than constructing it:
curl -s https://idp.authpi.com/{issuer_id}/.well-known/openid-configuration | jq -r .jwks_uri
# https://idp.authpi.com/{issuer_id}/jwks.json
The response is a standard key set:
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"kid": "7d3bde53-3945-49e4-8594-42e44d9f538e",
"x": "...", "y": "...",
"ext": true
},
{ "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": "...", "x": "...", "ext": true },
{ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "...", "n": "...", "e": "...", "ext": true }
]
}
What to rely on:
ES256 (EC P-256), EdDSA (Ed25519), and RS256 (RSA-2048). Which one signs a given client’s tokens follows the client’s response_signature_alg setting — see the token claims reference.kid against the set exactly; never parse meaning out of a kid or assume ordering.kid.use: "sig" and the public parameters; private parameters and internal metadata are stripped.| Property | Value |
|---|---|
| Rotation schedule | Monthly — last day of each month, 01:00 UTC |
| New keys per rotation | One per algorithm (ES256, EdDSA, RS256) |
| Old keys retained until | At least 45 days after creation |
| Effective publication lifetime | ~2 months (dropped at the first rotation after day 45) |
| Signing key | The newest key for the token’s algorithm |
| Longest-lived token | Refresh tokens, 7 days |
| Maximum configurable token lifetime | 21 days (enforced — API rejects higher) |
These numbers compose into the guarantee your verifier actually cares about: every token remains verifiable against the published JWKS for its entire lifetime. A signing key is superseded at the next rotation (at most ~31 days after its creation), but it isn’t removed until the first rotation after it turns 45 days old — roughly two months after creation. The gap between the last token a key signs and the key leaving the set is therefore always at least one full rotation period (≥ 28 days). With access tokens living minutes and refresh tokens 7 days by default, there is no moment where a valid token’s kid is missing from the JWKS.
AuthPI enforces this margin for you rather than leaving it to configuration. The three client token-age settings — default_access_token_age, default_id_token_age, and default_refresh_token_age — are capped at 21 days; the API rejects any higher value at create and update. On top of that, every token’s exp is clamped to at most 21 days from issuance at minting time, so a token can never outlive its signing key’s publication even if a longer lifetime slipped through. For long sessions, use refresh-token rotation rather than long-lived refresh tokens.
The flip side: a key you fetched and cached can gain siblings at any rotation, and keys older than 45 days disappear. Your verifier must treat the set as a cache to refresh, not a fixture to pin.
The endpoint sends:
Cache-Control: public, max-age=3600, s-maxage=3600, stale-if-error=120
ETag: "<hash of the key set>"
max-age=3600). The set changes at most monthly, so an hour of staleness is comfortably inside the 45-day overlap. Shared caches (CDNs, corporate proxies) may cache it too (public, s-maxage).If-None-Match. The ETag changes only when the key set does; a 304 Not Modified costs no body transfer. Send the ETag from your cached copy when refreshing.stale-if-error=120 lets caches serve a recently-expired copy for two minutes if a refresh fails — your verifier keeps working through a transient blip.Most JWT libraries handle all of this for you. jose’s createRemoteJWKSet caches the set, honors cache headers, and re-fetches on an unknown kid:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://idp.authpi.com/{issuer_id}/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://idp.authpi.com/{issuer_id}',
audience: 'your-api',
});
kidWhen a token’s kid is not in your cached set, the correct interpretation depends on freshness:
kid is still absent after a fresh fetch, reject the token. Do not retry further.Rate-limit the re-fetch (for example, at most once per minute per process): attackers can send tokens with random kid values, and an unthrottled verifier turns each one into an upstream request. createRemoteJWKSet implements exactly this cooldown; if you hand-roll verification, copy the behavior. The validate tokens guide has complete verifier examples.
kid.kid matching and “try every key”. It works until it masks a key-confusion bug. The header names the key; use it.ES256, EdDSA, RS256 — or just the one your client uses) so an attacker can’t downgrade verification.