advanced
JWKS
JWT
RS256

JWKS Key Management

Per-subaccount RS256 keypairs, kid rotation, grace windows, and where private keys are stored.

Levy Fleets TeamMay 18, 20269 min read

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:

ColumnWhat it holds
idUUID
subaccount_idFK to the operator subaccount
kidKey ID surfaced in the JWT header and JWKS response
algorithmAlways RS256 today
public_key_pemPEM-encoded SPKI public key
private_key_pemPEM-encoded PKCS#8 private key
is_activeExactly one row per subaccount has is_active = true
created_atMint time
rotated_atWhen this key stopped being active (null if still active)
expires_atWhen 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():

  1. Checks mds_jwks_keys for an is_active = true row.
  2. If none exists, generates a fresh RS256 keypair via Node's crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).
  3. Converts the PEM to JWK format (n, e, kty, alg, kid, use).
  4. Inserts the row with a freshly minted kid (format: mds-<YYYY-MM>-<short_hash>).
  5. 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:

1

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.

2

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.

3

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.

4

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.):

  1. Mint a new keypair immediately (steps above).
  2. Set the leaked row to expires_at = now() — it drops out of the JWKS instantly, so no in-flight requests can verify with it.
  3. Notify any cities that depend on JWT verification (they'll silently re-fetch the JWKS).
  4. Audit mds_policy_audit and 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-JWT header missing on a responsemds_jwks_keys has no is_active = true row. Hit /.well-known/jwks.json directly 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 kid matches 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