Skip to main content
Status: AcceptedArea: BackendDate: 2026-09-15

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