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.
Running a fleet on this hardware?
Levy Fleets runs scooters, e-bikes, mopeds and golf carts on the IoT hardware you already have, with integration pages for 29 vendors including Segway, Okai, Omni, Queclink, Invers and Comodule. Keyless unlock, geofencing, rider payments and rider support are included, and the Managed plan is a share of revenue with no per-vehicle software fee.