Asymmetric (ES256) internal JWT signing - #1693
Open
omrishiv wants to merge 20 commits into
Open
Conversation
Extract verify_self_signed_user_token() into auth_server/self_signed_token.py. All 5 IdP providers + the Cognito copy in server.py now delegate to this single implementation. Future asymmetric signing changes land in ONE place. Reconciled divergences: - SECRET_KEY: read from os.environ (no fallback default, fail closed) - method field: uses AUTH_METHOD_SELF_SIGNED constant consistently - leeway: 30s applied uniformly (was inconsistent across providers) - logging: masked subject, group/scope counts only (no PII)
InternalSigningKeyManager (auth_server/internal_signing_key.py): - Loads ES256 (P-256) private key from file (INTERNAL_SIGNING_KEY_PATH) or generates ephemeral key (INTERNAL_SIGNING_KEY_GENERATE=true for dev) - Live key rotation: polls file mtime (60s, matches kubelet Secret sync), adds new keys alongside old, retains old for 25h (covers 24h max TTL) - Kubernetes-native: mount as Secret volume, kubectl apply to rotate - Thread-safe concurrent sign/verify during rotation - JWKS endpoint at /.well-known/internal-jwks.json (unauthenticated, internal-only, serves all active keys) Dual-verify dispatch (auth_server/self_signed_token.py): - kid absent/None → HS256 path (SECRET_KEY, legacy tokens) - kid present → ES256 path (public key from key manager) - Algorithm-confusion prevention: separate _verify_hs256/_verify_es256 functions with distinct key variables (never mixed in one decode call) - Issuer checked BEFORE signature verification (rejects external IdP tokens claiming our issuer before they reach any verify path) - Token size bounded to 8KB before header parsing - Malformed header → hard reject (never falls through to HS256) Zero breaking change: existing tokens (no kid) verify via HS256 as before.
12 intent-based security tests validating the dual-verify dispatch cannot be exploited via algorithm confusion: Algorithm confusion attacks (manually-constructed HMAC tokens): - Public key as HS256 secret (no kid) → rejected (HS256 uses SECRET_KEY) - Public key as HS256 secret with kid → rejected (ES256 path rejects HS256) External IdP token confusion: - External key with our issuer → rejected (unknown kid) - Wrong issuer → rejected before signature verification Malformed tokens: - Oversized (>8KB), garbage, empty, alg:none → all hard-rejected Kid dispatch routing: - No kid → HS256 legacy path works - Valid kid → ES256 asymmetric path works - Unknown kid → rejected - Wrong token_use → rejected regardless of algorithm
The /internal/tokens endpoint now signs user-vended JWTs with ES256 (asymmetric) when InternalSigningKeyManager has a key available, falling back to HS256 (SECRET_KEY) when not configured. Minted tokens include a kid header matching the key manager's current kid. Verifiers dispatch on this kid: present → ES256 path, absent → HS256 legacy path. This enables a zero-downtime transition: 1. Deploy with INTERNAL_SIGNING_KEY_PATH set → minter starts using ES256 2. New tokens carry kid → verify via ES256 3. Old tokens (no kid) continue to verify via HS256 until they expire 4. After max-TTL (24h), all live tokens are ES256-signed No breaking change: without INTERNAL_SIGNING_KEY_PATH, behavior is identical to before (HS256 with SECRET_KEY).
After all live tokens have rotated to ES256 (>24h after minter switch), operators set REJECT_HS256_TOKENS=true to hard-reject legacy HS256 tokens. This closes the window where a leaked SECRET_KEY could forge tokens. The lever is a feature flag (not code deletion) so it can be reverted in one config change if issues arise. Recommended procedure: 1. Deploy with INTERNAL_SIGNING_KEY_PATH (minter switches to ES256) 2. Wait 24h+ (all issued tokens expire and get re-issued as ES256) 3. Set REJECT_HS256_TOKENS=true (legacy tokens hard-rejected) 4. After one release cycle with no issues, SECRET_KEY can be scoped down to session/encryption uses only Default: false (HS256 legacy tokens still accepted).
Docker Compose: - auth-server env: INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID, REJECT_HS256_TOKENS - auth-server volume: ./signing-key.pem mounted read-only build_and_run.sh: - Auto-generates signing-key.pem (ES256 P-256) if not present - chmod 600 on the generated key - Graceful fallback if openssl not available Helm (charts/auth-server): - values.yaml: signingKey.enabled, existingSecret, secretKey, kid, rejectHs256 - deployment.yaml: volume + volumeMount (conditional on signingKey.enabled) - secret.yaml: INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID, REJECT_HS256_TOKENS env vars .env.example: - Documented all new env vars with generation instructions .gitignore: - signing-key.pem excluded (generated private key) Kubernetes rotation: update the Secret, kubelet syncs, auth-server detects file change and adds new key to JWKS automatically.
_mint_internal_token and _decode_internal_token now use the ES256 key manager when available, with HS256 fallback for unconfigured deployments. This means ALL token minting in the auth-server (user-vended JWTs + internal service hop tokens) now uses the same ES256 private key when INTERNAL_SIGNING_KEY_PATH is configured. Holding SECRET_KEY no longer grants the ability to mint ANY token type. _decode_internal_token uses kid-based dispatch (same pattern as verify_self_signed_user_token): - kid present → ES256 (key manager public key) - kid absent → HS256 (SECRET_KEY, legacy) - REJECT_HS256_TOKENS=true → hard-reject legacy tokens Internal tokens have 30s TTL (vs 24h for user tokens), so the cutover window after switching is only 30 seconds for this token type.
The session cookie now carries the raw 256-bit hex session_id directly.
Security rests on unguessability (2^256 space) + mandatory DB lookup
(tampered id → no record → 401).
Format check (^[0-9a-f]{64}$) rejects garbage before the DB roundtrip
without needing any key material — no secrets, no state.
Legacy overlap: signed cookies from before this change still work via
a fallback signer.loads() path. After all sessions expire (max 8h
default), the legacy path can be removed.
This removes the registry's dependency on SECRET_KEY for session cookie
verification. The registry no longer needs SECRET_KEY for authentication
(only for CSRF signing and credential encryption, addressed separately).
Test updates: session_id fixtures now use proper 64-char hex strings
matching production format (secrets.token_hex(32)).
Add INTERNAL_SIGNING_KEY_PATH, INTERNAL_SIGNING_KEY_ID, REJECT_HS256_TOKENS to the remaining deployment surfaces: docker-compose.podman.yml: - Auth-server environment block + signing-key.pem volume mount docker-compose.prebuilt.yml: - Auth-server environment block + signing-key.pem volume mount terraform/aws-ecs/variables.tf: - internal_signing_key_secret_arn (Secrets Manager ARN for PEM key) - internal_signing_key_id (kid for rotation) - reject_hs256_tokens (cutover lever) terraform/aws-ecs/terraform.tfvars.example: - Documented example values with generation instructions Still remaining (follow-up before GA): - terraform/aws-ecs/modules/mcp-gateway/ (module-level vars + ecs-services.tf) - docs/unified-parameter-reference.md (cross-surface parameter index) - Scoped encryption keys (CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY, SESSION_TOKEN_ENC_KEY) across all surfaces - registry/api/config_routes.py (System Config page)
terraform/aws-ecs/modules/mcp-gateway/variables.tf: - internal_signing_key_secret_arn (Secrets Manager ARN for PEM key) - internal_signing_key_id (kid for rotation) - reject_hs256_tokens (cutover lever) terraform/aws-ecs/main.tf: - Pass all three vars into the module call terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf: - Map INTERNAL_SIGNING_KEY_ID and REJECT_HS256_TOKENS to auth-server container environment Note: INTERNAL_SIGNING_KEY_PATH is handled via Secrets Manager volume mount (not a plain env var) — that wiring requires the secret-rotation.tf pattern and is tracked separately.
Add CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY, SESSION_TOKEN_ENC_KEY to: - .env.example (documented with generation instructions + migration warning) - docker-compose.yml (registry + auth-server for SESSION_TOKEN_ENC_KEY) - docker-compose.podman.yml (same) - docker-compose.prebuilt.yml (same) All default to empty, which triggers the SECRET_KEY fallback in code. Existing deployments continue to work unchanged. New deployments can set scoped keys from the start for reduced blast radius. Migration note in .env.example: changing an encryption key invalidates data encrypted with the old key. Existing deployments should NOT set these unless accepting the re-login / re-configure impact.
docs/unified-parameter-reference.md: - INTERNAL_SIGNING_KEY_PATH (secret), INTERNAL_SIGNING_KEY_ID, REJECT_HS256_TOKENS - CSRF_SIGNING_KEY (secret), CREDENTIAL_ENCRYPTION_KEY (secret), SESSION_TOKEN_ENC_KEY (secret) - Each entry includes Docker env var, Terraform var, Helm values path, and purpose description with migration notes registry/api/config_routes.py: - Add scoped keys to the System Config page (all marked sensitive, values masked in UI via _mask_sensitive_value())
Tighten how the internal signing key, scoped secrets, and session cookies are handled across the auth-server, registry, and mcpgw-server, and fix a terraform parse break that blocked deploys. - Remove a dangling '},' in ecs-services.tf that broke terraform parse. - Remove the signing-key.pem mount from the registry and mcpgw-server containers — the private key is now mounted only in auth-server. - Add a JWKS endpoint (/.well-known/internal-jwks.json) to auth-server so verifiers fetch the public key instead of mounting the private one. - Make InternalSigningKeyManager a thread-safe singleton (double-checked locking with threading.Lock). - Validate scoped keys (CSRF_SIGNING_KEY, CREDENTIAL_ENCRYPTION_KEY) via validate_signing_secret when explicitly set — weak values are rejected at first use (fail closed, no silent fallback). - Set the raw session_id on the cookie write path to match the read path that accepts raw hex, removing the signed-write / unsigned-read discrepancy. - Update _last_mtime on key parse failure to avoid a hot-loop of retries when the key file is temporarily malformed. - Validate a minimum length (32 chars) in _get_secret_key() so a weak SECRET_KEY cannot be used to verify legacy HS256 tokens. - Strip exception details from ValueError messages to avoid information disclosure — log server-side, return a generic message to the caller. INTERNAL_SIGNING_KEY_PATH in the Secret is a path string, not a credential, so it is left as-is.
All surfaces now mount the signing key to the same path: /etc/mcp-gateway/signing-key/key.pem - Docker Compose (all 3 variants): bind mount to standardized path - Helm: Secret volume mount to /etc/mcp-gateway/signing-key/ with item key.pem (results in same final path) - Code: default constant checks /etc/mcp-gateway/signing-key/key.pem when INTERNAL_SIGNING_KEY_PATH env var is not set This means Kubernetes deployments need NO env var configuration — just create a Secret with the key and enable signingKey.enabled in Helm values. The code auto-detects the key at the standard path. Removed INTERNAL_SIGNING_KEY_PATH from Helm secret.yaml — the path is not a secret, and with the standardized mount point + auto-detect, it's not needed as configuration at all.
The session cookie carries a raw 256-bit hex session_id validated by a
server-side store lookup (no signature). registry/auth/dependencies.py was
updated to resolve the raw id, but auth-server's own validate_session_cookie
still called signer.loads() first, raising BadSignature on the unsigned id.
After OAuth login, auth-server's /validate rejected the freshly-set cookie
and redirected back to /login — an infinite login loop.
Both verifier processes (registry and auth-server) must accept the same
cookie format. This mirrors resolve_session_from_cookie: format-check
^[0-9a-f]{64}$ and resolve the raw id directly (no signer required), keeping
signer.loads() only as a legacy fallback for previously-signed cookies during
the rollover window. Unknown/expired sessions fail closed (401).
Tests: raw id validates with signer=None (the production path that was
broken); a legacy signed cookie still validates; garbage and unknown ids are
rejected; the valid_session_cookie fixture now uses the raw-id format.
… JWKS)
auth-server signs the internal hop tokens (mcp-registry-ui / mcp-proxy
audiences) with ES256 and a kid header when a signing key is configured,
falling back to HS256 otherwise. The registry's own verifier in
registry/auth/proxied_token.py was hardcoded to algorithms=["HS256"] with
SECRET_KEY, so it rejected every ES256 token ("The specified alg value is
not allowed"). With signing enabled this broke /api/auth/me and every
nginx-auth_request-fronted /api/ route → 401 → login loop.
- Add registry/auth/internal_jwks.py: a synchronous, TTL-cached fetcher for
auth-server's /.well-known/internal-jwks.json, indexed by kid, with
last-known-good on fetch failure (bounded staleness) and forced refresh on
an unknown kid (key rotation).
- proxied_token.py: extract _decode_internal_jwt with kid-based dispatch
mirroring auth-server's minter — kid present → ES256 via the fetched public
key; kid absent → HS256/SECRET_KEY legacy, honoring REJECT_HS256_TOKENS.
Both verify_registry_ui_token and verify_mcp_proxy_token route through it.
A malformed header is a hard 401 (never a fall-through to HS256); tokens
are size-bounded before parsing.
- config.py: internal_jwks_url (default the in-cluster auth-server endpoint)
+ internal_jwks_cache_ttl_seconds.
Tests: ES256 token verifies via the JWKS; verification works with SECRET_KEY
unset; unknown/wrong-key kid rejected; HS256 legacy still works and is
rejected under REJECT_HS256_TOKENS; JWKS cache hit/last-known-good/rotation/
empty-response behavior.
Signed-off-by: omrishiv <327609+omrishiv@users.noreply.github.com>
… key delivery Signing core (auth_server/internal_signing_key.py): - Derive kid from the RFC 7638 JWK thumbprint instead of a list-length counter, so kids are content-addressed, never reused across keys, and identical on every replica. Removes INTERNAL_SIGNING_KEY_ID entirely (a pinned kid breaks rotation). Fix _expire_old_keys to compare identity with `is`. - Add get_signing_material() -> (key, kid) under one lock; mint sites in internal_request_token.py and server.py use it (no key/kid TOCTOU on rotation). - Couple old-key retention default to MCP_TOKEN_MAX_TTL_HOURS (+1h, clamped to the 168h ceiling) so raising the token cap keeps still-valid tokens verifiable. REJECT_HS256 cutover: - Shared reject_hs256_tokens() helper (1/true/yes/on) reused by the self-signed and hop-token verify paths; registry parser aligned to the same set. - Flag now wired to the registry (verifier) service, not just auth-server, on compose (x3), Helm (new registry value + stack-mode render fix), and ECS. Verify path: - Validate SESSION_TOKEN_ENC_KEY with the canonical weak-before-length validator (fail closed), keeping the SECRET_KEY fallback (registry/auth/session_crypto.py). - _get_secret_key uses the canonical validator; groups:None returns [] not 500. - Bounded negative cache for unknown-kid JWKS refreshes to blunt random-kid fetch amplification (registry/auth/internal_jwks.py). Deploy surfaces: - ECS: deliver the ES256 PEM via a signing-key-init container that writes it to a task-scoped shared volume auth-server mounts read-only, sets INTERNAL_SIGNING_KEY_PATH, depends on init SUCCESS, and grants the secret ARN. Add optional scoped-key secret ARNs (CSRF/CREDENTIAL/SESSION) to both services. All gated on the signing-key ARN so an unconfigured deploy is unchanged (HS256). - Helm: reserved-env names, scoped keys, stack-mode rendering. - build_and_run.sh: fail hard if ES256 key generation fails. Docs: new docs/design/asymmetric-signing.md with the verification-flow diagram; update internal-hop-authentication.md and the unified parameter reference. Tests: add tests/auth_server/unit/test_internal_signing_key.py covering thumbprint kid, rotation overlap, retention expiry, and atomic signing material.
…op offload Deploy correctness (would break ES256 on EKS): - Helm signing-key secret mount 0400 -> 0440. The pod runs non-root (uid 1000 / fsGroup 1000) and k8s secret files are owned root:fsGroup, so 0400 (owner-only) gave the process EACCES and it silently fell back to HS256. 0440 is group-readable. - Wire the registry JWKS endpoint across all surfaces: new INTERNAL_JWKS_URL / INTERNAL_JWKS_CACHE_TTL_SECONDS in .env.example, compose x3, Terraform (vars + registry container env), Helm registry chart, CONFIG_GROUPS, and the unified reference. The Helm default is the in-namespace FQDN (auth-server.<ns>.svc...) because the bare service name the app default uses does not reliably resolve from the registry pod on EKS, which would 401 every ES256 hop token. Request-path availability: - Offload the synchronous JWKS-verify (which may block on a 5s httpx fetch) off the asyncio event loop via run_in_threadpool at the two registry callsites (dependencies._context_from_internal_token, egress vend_egress_token). - internal_jwks negative cache: evict-oldest instead of clearing the whole map under load, and rate-limit forced (TTL-bypassing) refreshes process-wide so a flood of unique/forged kids cannot amplify 1:1 into blocking fetches. Correctness / hygiene: - verify_self_signed_user_token chains exceptions (from e). - credential_encryption attributes a weak CREDENTIAL_ENCRYPTION_KEY to that key (not "SECRET_KEY"), still fail-closed. - JWKS Cache-Control max-age aligned to the registry cache TTL (300s). Docs: - theory-of-the-system.md: ES256 hop signing + unsigned session cookie (was HS256-only / signed-cookie), with an asymmetric-signing.md index entry. - asymmetric-signing.md: rotation + HS256->ES256 cutover runbook and deployment caveats (ECS init launch dependency, customer-managed KMS kms:Decrypt, k8s mount mode); CMK caveat also in tfvars.example + the module variable. - Umbrella stack chart surfaces the signing/cutover/scoped/JWKS toggles with a "flip rejectHs256 on both charts together" note. Tests: negative-cache dedupe, forced-refresh throttle, and REJECT_HS256 parse parity (auth-server <-> registry).
# Conflicts: # .env.example # charts/auth-server/reserved-env-names.txt # charts/mcp-gateway-registry-stack/values.yaml # charts/registry/reserved-env-names.txt # charts/registry/templates/secret.yaml # terraform/aws-ecs/modules/mcp-gateway/iam.tf # terraform/aws-ecs/modules/mcp-gateway/variables.tf # terraform/aws-ecs/variables.tf
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Asymmetric (ES256) internal JWT signing with kid rotation, JWKS, and dual-verify dispatch
Summary
Moves all auth-server JWT minting (user-vended tokens and internal service-hop tokens) from a single shared HS256
SECRET_KEYto ES256 (P-256) asymmetric signing with akidheader, a published JWKS endpoint, and live key rotation. Verification iskid-dispatched:kidpresent → ES256 (public key from JWKS),kidabsent → HS256 legacy. Zero breaking change — with no signing key configured, behavior is identical to today (HS256 +SECRET_KEY).Motivation / threat model
Today a single symmetric
SECRET_KEYboth signs and verifies every token, and it is mounted into every service. Consequences:SECRET_KEY(any container holding it, a leak, a backup) can forge any token type — user JWTs and internal hop tokens alike.ES256 splits signing (private key, auth-server only) from verification (public key via JWKS), so holding
SECRET_KEYno longer grants minting, verifiers never need the private key, and keys rotate online.What changed
Signing core (
auth_server/internal_signing_key.py, new)InternalSigningKeyManager: loads an ES256 P-256 private key fromINTERNAL_SIGNING_KEY_PATH(or generates an ephemeral dev key viaINTERNAL_SIGNING_KEY_GENERATE=true).GET /.well-known/internal-jwks.json— unauthenticated, internal-only, serves all active public keys (F9).Dual-verify dispatch (
auth_server/self_signed_token.py, new)server.py) into oneverify_self_signed_user_token().kid-based dispatch with separate_verify_hs256/_verify_es256functions (distinct key variables, never mixed in onedecode) — structurally prevents algorithm-confusion.Minters switch to ES256 (
server.py/internal/tokens,internal_request_token.py)kidheader when a key is configured, HS256 fallback otherwise. Internal hop tokens carry a 30s TTL, so their cutover window is ~30s.Registry-side verification (
registry/auth/internal_jwks.pynew,registry/auth/proxied_token.py)algorithms=["HS256"]and rejected every ES256 hop token → 401 loop on/api/auth/meand all nginx-auth_request-fronted/api/routes. Added a synchronous, TTL-cached JWKS fetcher (indexed bykid, last-known-good on failure, forced refresh on unknownkid) and routedverify_registry_ui_token/verify_mcp_proxy_tokenthrough the samekiddispatch. ([Phase 3] RFC 8707 resource enforcement + gateway token proxy (IdP-signed MCP tokens) #1692 might overlap, future refactor)Session cookie (
registry/auth/dependencies.py, auth-servervalidate_session_cookie)session_id; security rests on unguessability (2^256) + mandatory server-side store lookup. Format check^[0-9a-f]{64}$rejects garbage before the DB roundtrip with no key material. Legacy signed cookies still validate via a fallback during the ≤8h rollover. This removes the registry's dependency onSECRET_KEYfor auth. Both verifier processes accept the same format (fixes an OAuth-login infinite-loop where write set raw but read expected signed).Cutover lever (
REJECT_HS256_TOKENS, default false)SECRET_KEYforgery window. A flag (not code deletion) so it's revertible in one config change.Scoped encryption keys (
CSRF_SIGNING_KEY,CREDENTIAL_ENCRYPTION_KEY,SESSION_TOKEN_ENC_KEY)SECRET_KEYuses into purpose-scoped keys (reduced blast radius). All default empty →SECRET_KEYfallback, so existing deployments are unchanged. Weak values are validated (validate_signing_secret) and rejected at first use (fail closed).Deploy + docs
/etc/mcp-gateway/signing-key/key.pemacross Docker Compose (all 3 variants), Helm (Secret volume), and code auto-detect — so K8s needs no env config, just a Secret +signingKey.enabled.F2).INTERNAL_SIGNING_KEY_PATH/_ID,REJECT_HS256_TOKENS, and the scoped keys across Compose/podman/prebuilt, Helm, and Terraform ECS (module vars +ecs-services.tf);build_and_run.shauto-generatessigning-key.pem(chmod 600);.gitignoreexcludes it.docs/unified-parameter-reference.md+ System Config page (config_routes.py) document every new parameter (secrets masked in UI).Rollout (zero-downtime, staged)
INTERNAL_SIGNING_KEY_PATHset → minter starts signing ES256 (kidpresent); old no-kidtokens keep verifying via HS256.REJECT_HS256_TOKENS=true→ legacy HS256 hard-rejected.SECRET_KEYdown to session/encryption-only uses.Testing
tests/auth_server/unit/test_algorithm_confusion.py(new): 12 intent-based tests — public-key-as-HMAC-secret (with/withoutkid), external-IdP token confusion, wrong issuer, oversized/garbage/empty/alg:none, andkidrouting (no-kid HS256, valid-kid ES256, unknown-kid reject, wrongtoken_use).tests/unit/auth/test_internal_jwks.py(new): ES256 verifies via JWKS, works withSECRET_KEYunset, unknown/wrong-keykidrejected, HS256 legacy accepted (and rejected underREJECT_HS256_TOKENS), cache hit / last-known-good / rotation / empty-response.signer=None(the previously-broken production path), legacy signed cookie still validates, garbage/unknown rejected.Verified locally under the project
uvenv:test_internal_jwks.py6 passed;internal_request_token+algorithm_confusion+internal_jwksselection 39 passed.New files
auth_server/internal_signing_key.py,auth_server/self_signed_token.py,registry/auth/internal_jwks.py,tests/auth_server/unit/test_algorithm_confusion.py,tests/unit/auth/test_internal_jwks.py.Backwards compatibility
No signing key configured → identical HS256 +
SECRET_KEYbehavior. Legacy signed session cookies and no-kidHS256 tokens continue to verify until they expire (≤24h) orREJECT_HS256_TOKENSis set. Scoped encryption keys default to theSECRET_KEYfallback.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.