Status: AcceptedArea: BackendDate: 2026-09-15
Context
The key set that signs OAuth2 id_tokens and SSOprivate_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 withkms: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 gainskms:Signandkms:GetPublicKeyon it. - Two libraries must change. authlib signs id_tokens locally from the key material
(
authlib/oidc/core/grants/code.py:87), soencode_id_tokenis overridden on our ownIDTokenSigningmixin. reauth’sPrivateKeyJWTOIDCFactoralso 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
LocalKeyProviderdoes for encryption. - A retired public key stays in
/.well-known/jwks.jsonfor 7 days before removal. Shorter risks the third-party identity providers that cache our public set for SSO. RSASSA_PKCS1_V1_5_SHA_256is 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 — the envelope pattern, which this deliberately does not reuse.
- Rotating KMS keys and Key spec reference — the key types automatic rotation excludes, and the signing algorithms RSA keys support.
polar/kit/jwk.py,polar/oauth2/grants/authorization_code.py(IDTokenSigning).

