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 response — mds_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

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.