> ## Documentation Index
> Fetch the complete documentation index at: https://handbook.polar.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-0010: JWKS signing keys live in KMS and rotate by kid

> The private signing key never leaves KMS; replacing one is a publication problem, solved with kid.

<Info>
  **Status**: Accepted

  **Area**: Backend

  **Date**: 2026-09-15
</Info>

## Context

The key set that signs OAuth2 id\_tokens and SSO `private_key_jwt` assertions travels as
plaintext: a Terraform Cloud variable, mounted as a Render secret file and bundled into the
Lambda worker's Secrets Manager blob. It has never been rotated, and nothing says how to.
KMS cannot rotate it — automatic rotation covers only symmetric encryption keys whose
material AWS generates, and asymmetric keys are explicitly excluded — so both where the key
lives and how it is replaced need deciding rather than delegating.

## Decision

Keep the private key inside KMS: sign id\_tokens and SSO client assertions with `kms:Sign`,
and publish the matching public key from `kms:GetPublicKey`. Rotate by publishing a second
key and moving `CURRENT_JWK_KID`, never by asking KMS to rotate a key.

## Consequences

* The private key never exists in our process. A leaked configuration value, environment
  dump or Secrets Manager read yields nothing, and every signature is logged in CloudTrail.
* New rule: nothing holds key material. Code that signs asks the signer; code that publishes
  asks KMS. Freezing a key into a module-level constant breaks rotation, silently.
* This needs a **second KMS key** — asymmetric RSA, `KeyUsage=SIGN_VERIFY` — because key
  usage is fixed at creation and the existing symmetric key cannot sign. The role gains
  `kms:Sign` and `kms:GetPublicKey` on it.
* **Two libraries must change.** authlib signs id\_tokens locally from the key material
  (`authlib/oidc/core/grants/code.py:87`), so `encode_id_token` is overridden on our own
  `IDTokenSigning` mixin. reauth's `PrivateKeyJWTOIDCFactor` also takes a key set and signs
  locally, so it must accept a signer instead.
* A local signer keeps development and CI off AWS, mirroring what `LocalKeyProvider` does for
  encryption.
* A retired public key stays in `/.well-known/jwks.json` for 7 days before removal. Shorter
  risks the third-party identity providers that cache our public set for SSO.
* `RSASSA_PKCS1_V1_5_SHA_256` is exactly RS256, so the published set stays compatible and no
  merchant changes anything. AWS prefers RSASSA-PSS, but JOSE's PS256 would oblige every
  verifier to follow, so we keep RS256.
* Accepted trade-off: one KMS call per signature. Volume is low — one per authorization-code
  grant, one per SSO login — but a KMS outage stops both.

## Alternatives considered

* **Envelope-encrypt the key set and decrypt it at process start**: reuses ADR-0008 and needs
  no new KMS key, but the private key still ends up in process memory, and loading it at
  import needs a synchronous unwrap added to `polar/kit/encryption.py`. Rejected: it protects
  the key at rest without ever removing it from the process.
* **A table of keys, one row per key**: better audit trail, and the natural home for scheduled
  rotation since the retirement window becomes data rather than a runbook step. Puts the
  database on the signing path and needs a migration; deferred until rotation is automated
  rather than triggered by hand.
* **Leave it in plaintext and rotate by hand**: what we do today. Rotation means editing a
  Terraform Cloud variable and redeploying every consumer, with no procedure written down.

## References

* [ADR-0008: Encrypt secrets at rest](/engineering/decisions/0008-encrypt-secrets-at-rest) —
  the envelope pattern, which this deliberately does not reuse.
* [Rotating KMS keys](https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html)
  and [Key spec reference](https://docs.aws.amazon.com/kms/latest/developerguide/asymmetric-key-specs.html)
  — the key types automatic rotation excludes, and the signing algorithms RSA keys support.
* `polar/kit/jwk.py`, `polar/oauth2/grants/authorization_code.py` (`IDTokenSigning`).
