JWKS Key Management
Every MDS 2.0 response carries an MDS-JWT header — an RS256-signed JWT that proves the response body sha256 came from your subaccount. The public side of that keypair is published at .well-known/jwks.json. This article covers how those keys are stored, rotated, and recovered.
Storage
Keys live in the mds_jwks_keys table, one or more rows per subaccount:
| Column | What it holds |
|---|---|
id | UUID |
subaccount_id | FK to the operator subaccount |
kid | Key ID surfaced in the JWT header and JWKS response |
algorithm | Always RS256 today |
public_key_pem | PEM-encoded SPKI public key |
private_key_pem | PEM-encoded PKCS#8 private key |
is_active | Exactly one row per subaccount has is_active = true |
created_at | Mint time |
rotated_at | When this key stopped being active (null if still active) |
expires_at | When the key falls out of the JWKS grace window |
Private key storage
private_key_pem is currently stored unencrypted in Supabase. Moving it to pgsodium / Supabase Vault is on the roadmap before we ship to a paying enterprise customer. Treat the row as sensitive — do not include it in exports, backups not encrypted at rest, or share it with cities.
Lazy minting
On first call to either /api/mds/{subaccountId}/.well-known/jwks.json or any /provider/v2/* endpoint, getOrCreateActiveJwksKey():
- Checks
mds_jwks_keysfor anis_active = truerow. - If none exists, generates a fresh RS256 keypair via Node's
crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }). - Converts the PEM to JWK format (
n,e,kty,alg,kid,use). - Inserts the row with a freshly minted
kid(format:mds-<YYYY-MM>-<short_hash>). - Returns the active row to the caller.
This means an operator can be fully onboarded with zero manual key setup — the first ping from the city's validator triggers the mint.
Rotation
Yearly rotation is the recommended cadence, matching OMF's guidance.
To rotate:
Mint a new key
Insert a new row with is_active = true and a fresh kid. The keypair is generated server-side; you never touch the private key.
Demote the previous key
Set is_active = false and rotated_at = now() on the previous active row. Set expires_at = now() + 7 days to give cities time to refresh their JWKS cache.
Confirm the JWKS publishes both
Hit /.well-known/jwks.json and verify both kids appear in the keys array. JWTs signed before rotation continue to verify; new responses use the new key.
After the grace window
Delete the demoted row (or mark it expires_at = now()). It drops out of the JWKS automatically.
JWKS response shape
{
"keys": [
{
"kty": "RSA",
"kid": "mds-2026-05-a7c2",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQ...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "mds-2025-05-9f1d",
"use": "sig",
"alg": "RS256",
"n": "1vyx8wagoeb2X...",
"e": "AQAB"
}
]
}
Cities pick the right key by matching the JWT header's kid against the kids in the JWKS. We publish active + grace-window keys both; rotated-and-expired keys are never published.
Verifying a JWT locally
The OMF mds-provider-validator does this automatically, but you can verify by hand:
# Pull a response
curl -i -H "Authorization: Bearer <token>" \
https://fleets.levyelectric.com/api/mds/<subaccountId>/provider/v2/vehicles/status > resp.txt
# Extract the JWT
JWT=$(grep -i '^MDS-JWT:' resp.txt | awk '{print $2}' | tr -d '\r')
# Fetch the JWKS
curl https://fleets.levyelectric.com/api/mds/<subaccountId>/.well-known/jwks.json > jwks.json
# Verify with mkjwk or jose CLI
jose jws ver -i <(echo "$JWT") -k <(jq '.keys[0]' jwks.json)
A clean verification returns payload verified plus the JWT payload (issuer, audience, body sha256, iat, exp).
Recovery if a private key leaks
If the private key is exposed (logged accidentally, shared with a city by mistake, etc.):
- Mint a new keypair immediately (steps above).
- Set the leaked row to
expires_at = now()— it drops out of the JWKS instantly, so no in-flight requests can verify with it. - Notify any cities that depend on JWT verification (they'll silently re-fetch the JWKS).
- Audit
mds_policy_auditand Sentry for any anomalous requests in the leak window.
Bearer-token auth is unaffected by JWKS leaks — the JWT is an additional integrity layer, not the primary credential. Cities that only validate bearer tokens see no impact.
Troubleshooting
MDS-JWTheader missing on a response —mds_jwks_keyshas nois_active = truerow. Hit/.well-known/jwks.jsondirectly to force a lazy mint.- JWKS returns
{"keys": []}— likely the same root cause. The lazy mint should populate it on the next read. - JWT verification fails with "kid mismatch" — the city's JWKS cache is stale. They should re-fetch and retry. If still failing, confirm the active row's
kidmatches what the response header carries. - JWT verification fails with "signature invalid" — the public key in JWKS doesn't match the private key that signed the JWT. Most likely two writes raced in the lazy mint; deactivate the older row to clear it.
What's next
- MDS Provider Setup — full endpoint reference.
- Troubleshooting — when JWTs or JWKS misbehave.