Skip to content

added new stack server secret#1352

Open
aadesh18 wants to merge 8 commits intodevfrom
stack-server-secret-rotation
Open

added new stack server secret#1352
aadesh18 wants to merge 8 commits intodevfrom
stack-server-secret-rotation

Conversation

@aadesh18
Copy link
Copy Markdown
Contributor

@aadesh18 aadesh18 commented Apr 20, 2026

This PR is for rotating the STACK_SERVER_SECRET. Right now we are using the secret for creating and verifying jwts. For the rotation, we will essentially need to do two deployments. We first deploy with a new stack server secret and an old stack server secret both. We sign the new jwts with the new stack server secret. The verification takes place with the old stack server secret and the new stack server secret. After a while [a day?], we do another deployment where we just use the new stack server secret and not the old one for signing and verification both.

Summary by CodeRabbit

  • New Features

    • Support for server-secret rotation: authentication tokens and session cookies encrypted with either the current or previous secret are accepted during a grace period.
  • Tests

    • New unit and end-to-end tests for JWT signing/verification, JWKS overlap, cookie key acceptance, refresh/revocation flows, and rotation scenarios.
  • Chores

    • Added an environment configuration entry to specify the previous server secret for rotation.

@aadesh18 aadesh18 self-assigned this Apr 20, 2026
Copilot AI review requested due to automatic review settings April 20, 2026 03:34
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-backend Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-dashboard Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-demo Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-docs Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-preview-backend Ready Ready Preview, Comment Apr 20, 2026 6:19am
stack-preview-dashboard Ready Ready Preview, Comment Apr 20, 2026 6:19am

@aadesh18 aadesh18 changed the title added new stack server token added new stack server secret Apr 20, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Backend and shared JWT utilities now support secret rotation by deriving and accepting keys from both STACK_SERVER_SECRET and STACK_SERVER_SECRET_OLD for JWKS, JWT verification, and OIDC cookie encryption. New unit and E2E tests plus env/example updates validate overlap behavior.

Changes

Cohort / File(s) Summary
Environment Configuration
docker/server/.env, docker/server/.env.example
Add STACK_SERVER_SECRET_OLD and document it as the prior secret used during rotation; example secret values updated.
JWT Key Derivation & Utilities
packages/stack-shared/src/utils/jwt.tsx
Add getOldStackServerSecret(); refactor per-audience secret derivation to accept an explicit secret; getPrivateJwks() now returns keypairs derived from the primary secret and, when present/different, the old secret (concatenated).
OIDC Provider Cookie Keys
apps/backend/src/app/api/latest/integrations/idp.ts
createOidcProvider conditionally appends an old-secret-derived cookie key to cookies.keys so cookies encrypted with either secret can be verified.
Unit Tests (JWT utils)
packages/stack-shared/src/utils/jwt.test.ts
New Vitest suite covering signing/verification across rotation, JWKS composition, cookie-key overlap, expiration/tamper rejection, and invalid-old-secret errors.
End-to-end Tests
apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts
New E2E tests validating JWKS shape and count during rotation, token verification vs live JWKS, refresh flows, and revocation behavior with dual-secret overlap.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(200,230,255,0.5)
    participant Client
  end
  rect rgba(220,255,200,0.5)
    participant Server as Backend/IDP
    participant JWKS as KeyUtils/JWKS
    participant Store as CookieStore
  end

  Client->>Server: Authenticate / request token
  Server->>JWKS: Derive signing key(s) from STACK_SERVER_SECRET (+ optional STACK_SERVER_SECRET_OLD)
  JWKS-->>Server: Private JWK(s) (kid_new[, kid_old])
  Server-->>Client: Issue JWT (kid_new) + Set cookie (encrypted with key_new)
  Client->>Server: Request with cookie / JWT
  Server->>Store: Read cookie
  alt cookie encrypted with new key
    Server->>Server: Decrypt with key_new
  else cookie encrypted with old key
    Server->>Server: Decrypt with key_old (from STACK_SERVER_SECRET_OLD)
  end
  Server->>JWKS: Verify JWT using union of public keys (kid_new, kid_old)
  JWKS-->>Server: Verification result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • N2D4

Poem

🐰 Two keys in my burrow, one old, one new,
I hop through bytes and keep secrets true;
Cookies and tokens snug in a heap,
Overlap lets rotations leap—
Hoppity-hop, secure and new. ✨🔑

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is vague and generic, using non-descriptive language ('added new stack server secret') that doesn't convey the actual purpose: implementing a two-phase secret rotation mechanism. Revise the title to clarify the main objective, such as 'Implement STACK_SERVER_SECRET rotation with dual-secret verification' or 'Add STACK_SERVER_SECRET_OLD for secret rotation support'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description provides a clear two-phase rollout strategy and explains the rotation mechanism adequately, addressing the core purpose of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch stack-server-secret-rotation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Updated comment for STACK_SERVER_SECRET_OLD to clarify its purpose.
@aadesh18 aadesh18 requested a review from N2D4 April 20, 2026 03:36
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 20, 2026

Greptile Summary

This PR implements server-secret rotation by deriving a second set of JWKs and OIDC cookie keys from a previous server secret (STACK_SERVER_SECRET_OLD) and accepting both during the overlap window, while signing new tokens exclusively with the current secret.

  • P1 — startup crash on non-rotation deployments: getOldStackServerSecret() calls getEnvVariable(\"STACK_SERVER_SECRET_OLD\") without a default, so the function throws whenever the variable is absent or empty. The shipped docker/server/.env sets the variable to an empty string, meaning any Docker deployment using it will crash immediately on startup.
  • P1 — misleading operator docs: docker/server/.env.example instructs operators to "Remove after the grace window," but removing or emptying the variable causes the same crash. The correct post-rotation steady state is to keep the variable equal to the current server secret.

Confidence Score: 3/5

Not safe to merge — the shipped docker default env and the operator doc both lead to a startup crash for any non-rotating deployment.

Two P1 issues create a real production reliability problem: the docker/server/.env ships with the old-secret variable empty, and getOldStackServerSecret throws on empty/missing values, so the server crashes on startup. The misleading .env.example comment compounds the risk for operators following the runbook.

docker/server/.env, packages/stack-shared/src/utils/jwt.tsx, and docker/server/.env.example all need fixes before this can be safely deployed.

Important Files Changed

Filename Overview
packages/stack-shared/src/utils/jwt.tsx Adds getOldStackServerSecret() and refactors getPrivateJwks to derive a key pair from both the current and previous server secrets — but getOldStackServerSecret calls getEnvVariable without a default, causing a crash whenever the old-secret variable is unset or empty (the non-rotation steady state).
apps/backend/src/app/api/latest/integrations/idp.ts Appends the old-secret-derived cookie key to oidc-provider's cookies.keys array during a rotation window so OIDC session cookies remain valid; the spread correctly puts the old key at index 1 (verify-only).
docker/server/.env Ships with the old-secret variable set to an empty string; because getEnvVariable throws for empty/missing vars, any Docker deployment using this file as-is will crash on startup.
docker/server/.env.example Documents the new old-secret variable, but the "Remove after the grace window" comment is incorrect — removing the variable causes a startup crash; it must remain set to the current server secret.
packages/stack-shared/src/utils/jwt.test.ts Comprehensive unit tests covering signing, verification, OIDC cookie key derivation, JWKS overlap, expiry rejection, and tamper detection across steady-state and rotation environments.
apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts New e2e test suite verifying live JWKS output, token signing/verification, refresh/revocation flows, and private-scalar non-exposure; minor leading-whitespace lint issue on the first import line.

Sequence Diagram

sequenceDiagram
    participant GS as getPrivateJwks()
    participant D1 as derivePairForSecret(primary)
    participant D2 as derivePairForSecret(previous)
    participant S as signJWT()
    participant V as verifyJWT()

    Note over GS: Deploy 1 rotation window
    GS->>D1: primary pair (2 JWKs from new secret)
    GS->>D2: old pair (2 JWKs from previous secret)
    GS-->>S: returns 4-entry list
    S->>S: signs with index 0 only
    V->>V: verifies against all 4 keys

    Note over GS: Steady state (both secrets equal)
    GS->>D1: pair from current secret
    GS->>D2: duplicate pair from same secret
    GS-->>S: 4 entries, 2 unique kids
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts
Line: 1

Comment:
**Leading whitespace on first import**

The first line has 2 extra spaces of indentation before the `import` statement, which will fail lint/formatting checks.

```suggestion
import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes";
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: docker/server/.env.example
Line: 7

Comment:
**"Remove after the grace window" instruction will break deployments**

`STACK_SERVER_SECRET_OLD` is required — `getEnvVariable("STACK_SERVER_SECRET_OLD")` (no default) throws when the variable is absent or empty. Instructing operators to remove the variable after the grace window will cause their server to fail to start. The correct steady-state is to keep it set to the current `STACK_SERVER_SECRET` value (not empty, not unset).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: docker/server/.env
Line: 10

Comment:
**Empty old-secret value causes startup failure**

`getOldStackServerSecret()` calls `getEnvVariable(...)` with no default, so an empty or missing value triggers an immediate `throwErr("Missing environment variable")`. This file ships with the old-secret variable set to an empty string, which means any Docker deployment using this file as-is crashes on startup before serving any requests.

Per the unit-test comment, the variable is required. In steady state it should equal the current server secret; during rotation it holds the previous value.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/stack-shared/src/utils/jwt.tsx
Line: 30-38

Comment:
**`getOldStackServerSecret` throws instead of returning falsy when the var is unset**

`getEnvVariable` (called without a default) throws when the variable is absent or empty. All callers rely on a falsy return to detect the non-rotation case (e.g. `oldSecret ? await derivePairForSecret(oldSecret) : []`). With this code, every non-rotation deployment — including the shipped docker `.env` which leaves the old-secret variable empty — crashes on startup.

If the design requires the variable to always be set, the docker files and operator docs must enforce that explicitly. Alternatively, add a default of `""` and return early to make the non-rotation case safe:

```
const val = getEnvVariable("STACK_SERVER_SECRET_OLD", "");
if (!val) return "";
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "Added tests" | Re-trigger Greptile

Comment thread packages/stack-shared/src/utils/jwt.tsx Outdated
Comment thread apps/backend/src/app/api/latest/integrations/idp.ts Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a two-phase STACK_SERVER_SECRET rotation mechanism so newly minted JWTs/cookies use the new secret while verification temporarily accepts artifacts signed/encrypted with the previous secret.

Changes:

  • Add STACK_SERVER_SECRET_OLD support to JWT key derivation so verification accepts both old/new secrets during the overlap window.
  • Extend OIDC-provider cookie keyring to include an old-secret-derived key for decrypting/verifying pre-rotation cookies.
  • Add vitest coverage pinning rotation invariants and update Docker env templates to document the new variable.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/stack-shared/src/utils/jwt.tsx Adds optional “old secret” handling to expand the verification key set during rotation.
packages/stack-shared/src/utils/jwt.test.ts Adds tests validating JWT/JWKS behavior across rotation overlap scenarios.
docker/server/.env.example Documents STACK_SERVER_SECRET_OLD in the example server env file.
docker/server/.env Documents STACK_SERVER_SECRET_OLD in the template env file.
apps/backend/src/app/api/latest/integrations/idp.ts Appends old-secret-derived cookie key so OIDC cookies remain readable during the grace window.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/stack-shared/src/utils/jwt.tsx Outdated
Comment thread apps/backend/src/app/api/latest/integrations/idp.ts
Comment thread packages/stack-shared/src/utils/jwt.tsx
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/backend/src/app/api/latest/integrations/idp.ts (1)

179-187: Avoid double-invocation of getOldStackServerSecret() and switch to explicit null check.

getOldStackServerSecret() is called twice — once for the guard and once inside the template literal. Each call re-reads process.env and re-runs jose.base64url.decode, and the two reads are not atomic (an unlikely but real footgun if env changes mid-request, e.g. via a test harness). Cache once.

This also pairs with the return-type fix suggested in jwt.tsx — once that returns null for "unset", the guard here should be an explicit != null check per coding guidelines.

♻️ Proposed fix
     cookies: {
       // oidc-provider passes these to Koa keygrip: index 0 signs new cookies, any entry verifies.
       // During a STACK_SERVER_SECRET rotation, the old-secret-derived key is appended so cookies
       // issued before the rotation remain readable until they expire naturally.
-      keys: [
-        toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getEnvVariable("STACK_SERVER_SECRET")}`)),
-        ...(getOldStackServerSecret()
-          ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getOldStackServerSecret()}`))]
-          : []),
-      ],
+      keys: await (async () => {
+        const oldSecret = getOldStackServerSecret();
+        return [
+          toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getEnvVariable("STACK_SERVER_SECRET")}`)),
+          ...(oldSecret != null
+            ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${oldSecret}`))]
+            : []),
+        ];
+      })(),
     },

Or, more cleanly, compute keys into a local above the new Provider(...) call.

As per coding guidelines: "Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/app/api/latest/integrations/idp.ts` around lines 179 - 187,
Cache the result of getOldStackServerSecret() into a local variable (e.g., const
oldSecret = getOldStackServerSecret()) and compute the keys array in a local
variable before calling new Provider(...); call sha512/toHexString once for each
needed secret using getEnvVariable("STACK_SERVER_SECRET") and oldSecret, and use
an explicit null check (oldSecret != null) instead of a truthy guard so you only
invoke getOldStackServerSecret() one time and avoid double-invocation in the
keys array.
packages/stack-shared/src/utils/jwt.tsx (1)

120-152: Verify downstream JWKS consumer assumptions before merging.

The refactor is sound: signing uses privateJwks[0] (legacy derivation of current primary), and old-secret keys are appended when configured. The JWKS endpoint is correctly implemented with 1-hour caching.

However, the key count range deserves validation: up to 6 keys published normally (2 per audience × 3 audiences), scaling to 12 during dual-secret overlap. While not large, confirm that no downstream consumer (SDKs, clients with limited key caches, or rate-limited JWKS fetch patterns) assumes bounded key counts. Test #10 covers the dual-secret rotation invariant for a single audience; consider adding coverage for the full 3-audience overlap scenario if not already present.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/utils/jwt.tsx` around lines 120 - 152, The change
can publish up to 6 keys (or 12 during dual-secret overlap) from
getPrivateJwks/derivePairForSecret/getPrivateJwkFromDerivedSecret when three
audiences are used; before merging, validate downstream JWKS consumers (SDKs,
clients, caches) do not assume a smaller bounded key set and won't break or
rate-limit on larger JWKS responses, and add a unit/integration test that
simulates all three audiences plus dual-secret overlap to assert the JWKS
endpoint returns the expected combined key count and that signing still uses
privateJwks[0]; if any consumer cannot handle the larger set, either document
the new key-count behavior clearly or limit published keys (e.g., prefer current
primary only) and add a note in the codepaths around
getPrivateJwks/getStackServerSecret/getOldStackServerSecret.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/stack-shared/src/utils/jwt.tsx`:
- Around line 30-38: getOldStackServerSecret currently always returns a string
("" when unset) which mismatches its declared string | null; change
getOldStackServerSecret to return null when the env value is the empty string
(i.e., after reading STACK_SERVER_SECRET_OLD, if it's "" return null, otherwise
validate with jose.base64url.decode and return the non-empty string); then
update callers to check != null (not truthy) and avoid double calls: update
jwt.tsx usage around derivePairForSecret(oldSecret) to use a stored oldSecret !=
null ? [...], update jwt.test.ts to conditionally include deriveOidcCookieKey
only when old !== null, and in idp.ts read getOldStackServerSecret() once into a
local (e.g., oldSecret) and use oldSecret != null to decide the branch.

---

Nitpick comments:
In `@apps/backend/src/app/api/latest/integrations/idp.ts`:
- Around line 179-187: Cache the result of getOldStackServerSecret() into a
local variable (e.g., const oldSecret = getOldStackServerSecret()) and compute
the keys array in a local variable before calling new Provider(...); call
sha512/toHexString once for each needed secret using
getEnvVariable("STACK_SERVER_SECRET") and oldSecret, and use an explicit null
check (oldSecret != null) instead of a truthy guard so you only invoke
getOldStackServerSecret() one time and avoid double-invocation in the keys
array.

In `@packages/stack-shared/src/utils/jwt.tsx`:
- Around line 120-152: The change can publish up to 6 keys (or 12 during
dual-secret overlap) from
getPrivateJwks/derivePairForSecret/getPrivateJwkFromDerivedSecret when three
audiences are used; before merging, validate downstream JWKS consumers (SDKs,
clients, caches) do not assume a smaller bounded key set and won't break or
rate-limit on larger JWKS responses, and add a unit/integration test that
simulates all three audiences plus dual-secret overlap to assert the JWKS
endpoint returns the expected combined key count and that signing still uses
privateJwks[0]; if any consumer cannot handle the larger set, either document
the new key-count behavior clearly or limit published keys (e.g., prefer current
primary only) and add a note in the codepaths around
getPrivateJwks/getStackServerSecret/getOldStackServerSecret.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8844b9d6-59a0-421f-aa72-3098f0d93749

📥 Commits

Reviewing files that changed from the base of the PR and between be6970c and 1db4407.

📒 Files selected for processing (5)
  • apps/backend/src/app/api/latest/integrations/idp.ts
  • docker/server/.env
  • docker/server/.env.example
  • packages/stack-shared/src/utils/jwt.test.ts
  • packages/stack-shared/src/utils/jwt.tsx

Comment thread packages/stack-shared/src/utils/jwt.tsx Outdated
@mantrakp04
Copy link
Copy Markdown
Collaborator

Clean Code review — Major finding

getOldStackServerSecret return type lies about null

Location: packages/stack-shared/src/utils/jwt.tsx:30-38

Issue: The JSDoc says "or null if none is set" and the signature is string | null, but the function returns "" (from getEnvVariable("STACK_SERVER_SECRET_OLD", "")) — never null. Both call sites (jwt.tsx:147 and idp.ts:181-183) rely on truthiness, so the empty string accidentally behaves as "none." A future caller reading the type will reasonably write if (old === null) and get wrong behavior.

Also: jose.base64url.decode("") happens to succeed (empty buffer), so the validation only catches a non-empty-but-malformed value by coincidence, not by design.

Suggested fix: Normalize unset → null explicitly, and validate only when set:

export function getOldStackServerSecret(): string | null {
  const raw = getEnvVariable("STACK_SERVER_SECRET_OLD", "");
  if (!raw) return null;
  try {
    jose.base64url.decode(raw);
  } catch (e) {
    throw new StackAssertionError("STACK_SERVER_SECRET_OLD is set but not a valid base64url string. Remove it, or set it to the previous STACK_SERVER_SECRET value.", { cause: e });
  }
  return raw;
}

This makes the signature honest and removes the accidental-validation-on-empty-string footgun.

Comment thread packages/stack-shared/src/utils/jwt.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
packages/stack-shared/src/utils/jwt.tsx (1)

30-37: ⚠️ Potential issue | 🔴 Critical

Return null when STACK_SERVER_SECRET_OLD is unset.

The helper still requires and returns a string, so the “new secret only” phase can fail instead of omitting secondary keys. Normalize the optional env var to null and use an explicit nullish check at the call site.

Proposed fix
-export function getOldStackServerSecret(): string {
-  const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD");
+export function getOldStackServerSecret(): string | null {
+  const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD", "");
+  if (STACK_SERVER_SECRET_OLD === "") return null;
   try {
     jose.base64url.decode(STACK_SERVER_SECRET_OLD);
   } catch (e) {
     throw new StackAssertionError("STACK_SERVER_SECRET_OLD is set but not a valid base64url string. Remove it, or set it to the previous STACK_SERVER_SECRET value.", { cause: e });
   }
   return STACK_SERVER_SECRET_OLD;
 }
   const primaryPair = await derivePairForSecret(getStackServerSecret());
   const oldSecret = getOldStackServerSecret();
-  const oldPair = oldSecret ? await derivePairForSecret(oldSecret) : [];
+  const oldPair = oldSecret != null ? await derivePairForSecret(oldSecret) : [];

Also applies to: 147-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/utils/jwt.tsx` around lines 30 - 37, Update
getOldStackServerSecret to treat STACK_SERVER_SECRET_OLD as optional: if
getEnvVariable("STACK_SERVER_SECRET_OLD") returns undefined/null/empty, return
null instead of throwing or returning a string; only call jose.base64url.decode
when the env value is present and, on decode failure, throw the existing
StackAssertionError with the decode error as cause. Change the function
signature/return type to allow null and ensure the callers use an explicit
nullish check before using the returned value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts`:
- Around line 52-68: The test currently fetches the JWKS after sign-up so it
verifies against the refreshed keys; instead, before calling
Auth.Password.signUpWithEmail() use niceBackendFetch(INTERNAL_JWKS_PATH) to
snapshot/cache the pre-rotation JWKS, then perform the sign-up
(Auth.Password.signUpWithEmail) to trigger the key-roll, obtain the minted
access token from backendContext.value.userAuth?.accessToken, and verify that
token cryptographically against the cached set (use jose.createLocalJWKSet on
the cached jwks and jose.jwtVerify) to ensure a verifier built from the old JWKS
still validates tokens minted during rollout; afterwards you can fetch the live
JWKS and assert the new kid is present if needed.
- Around line 29-50: getPrivateJwks returns duplicate JWKS when
STACK_SERVER_SECRET equals STACK_SERVER_SECRET_OLD; change getPrivateJwks to
detect identical secrets (compare STACK_SERVER_SECRET and
STACK_SERVER_SECRET_OLD or derived public values) and skip adding the old-secret
key pair when identical so it yields only the primary pair, and update the test
in secret-rotation.test.ts to assert exactly 2 unique kids when secrets are
identical and exactly 4 unique kids when they differ (use getPrivateJwks or the
env comparison to decide which assertion to run), referencing getPrivateJwks and
the JWKS endpoint check in the test.

In `@packages/stack-shared/src/utils/jwt.test.ts`:
- Around line 73-145: Tests 1, 3, and 5 assert the wrong rotation order: Deploy
1 must continue signing with the old secret while publishing the new JWKS, not
immediately sign with the new secret. Update the specs that reference signJWT,
getPrivateJwks, setRotatingEnv, setSteadyStateEnv, verifyJWT, and
jose.decodeProtectedHeader so that Phase 1 (cached-JWKS) asserts Deploy 1 still
signs with the old-secret kid and that new JWKS are published (i.e.,
getPrivateJwks returns the new kid but signJWT returns old kid), and move the
assertions that new tokens are signed with the new secret into Phase 2 /
post-cache tests; also add an explicit cached-JWKS Phase 1 test to confirm
cached verification behavior during overlap.
- Around line 25-31: The helper setSteadyStateEnv currently sets
STACK_SERVER_SECRET_OLD equal to STACK_SERVER_SECRET which masks the "no
secondary secret" path; change setSteadyStateEnv to set
process.env.STACK_SERVER_SECRET = secret and remove/unset
process.env.STACK_SERVER_SECRET_OLD (delete or set to undefined) so tests model
a steady state with no secondary secret; update any references in jwt.test.ts
that expect duplicate JWKS entries to instead handle the single-secret steady
state via setSteadyStateEnv.

---

Duplicate comments:
In `@packages/stack-shared/src/utils/jwt.tsx`:
- Around line 30-37: Update getOldStackServerSecret to treat
STACK_SERVER_SECRET_OLD as optional: if
getEnvVariable("STACK_SERVER_SECRET_OLD") returns undefined/null/empty, return
null instead of throwing or returning a string; only call jose.base64url.decode
when the env value is present and, on decode failure, throw the existing
StackAssertionError with the decode error as cause. Change the function
signature/return type to allow null and ensure the callers use an explicit
nullish check before using the returned value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 520240ae-64f7-4187-b531-45cd58631358

📥 Commits

Reviewing files that changed from the base of the PR and between 1db4407 and 5e7eaf0.

📒 Files selected for processing (4)
  • apps/backend/src/app/api/latest/integrations/idp.ts
  • apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts
  • packages/stack-shared/src/utils/jwt.test.ts
  • packages/stack-shared/src/utils/jwt.tsx

Comment thread apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts Outdated
Comment thread apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts Outdated
Comment thread packages/stack-shared/src/utils/jwt.test.ts Outdated
Comment thread packages/stack-shared/src/utils/jwt.test.ts
Comment thread docker/server/.env.example
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/stack-shared/src/utils/jwt.test.ts (1)

73-82: ⚠️ Potential issue | 🟠 Major

Re-check Deploy 1 signing against cached JWKS consumers.

These specs still require new tokens to be signed with the new kid immediately once both secrets are configured. If any verifier holds a pre-Deploy-1 JWKS cache, those tokens can fail verification until cache refresh; Phase 1 should publish the new key while continuing to sign with the old key, then flip signing after the cache window if cached JWKS consumers are in scope.

Also applies to: 99-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/utils/jwt.test.ts` around lines 73 - 82, The test
assumes immediate flip-to-new-kid signing after setRotatingEnv, but Phase 1
requires publishing the new key while continuing to sign with the old key so
cached JWKS consumers don't fail; update the test around setRotatingEnv,
signJWT, getPrivateJwks, verifyJWT and jose.decodeProtectedHeader to assert that
after calling setRotatingEnv the new JWKS entry exists (getPrivateJwks contains
the new kid) while the freshly signed JWT still carries the previous (old) kid
and verifies successfully; then separately simulate the post-cache-refresh path
(or trigger the rotate-to-new-signing step) and assert that subsequent signJWT
tokens use the new kid. Ensure you reference setRotatingEnv, signJWT, verifyJWT,
getPrivateJwks and jose.decodeProtectedHeader when making these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/stack-shared/src/utils/jwt.test.ts`:
- Around line 113-116: The test is bypassing type safety by casting header.kid
(and another double-cast at variable k); update the tests to remove the "as
string" and double-casts: after decoding the JWT header in the signJWT flow
(variable header from jose.decodeProtectedHeader) add an explicit null/undefined
guard like if (header.kid == null) throw new Error(...) before calling
preRotationKids.has or other assertions, and replace the double-cast assertion
on k with expect(k).not.toHaveProperty("d") so the type system is respected;
keep the existing assertions around verifyJWT unchanged.

---

Duplicate comments:
In `@packages/stack-shared/src/utils/jwt.test.ts`:
- Around line 73-82: The test assumes immediate flip-to-new-kid signing after
setRotatingEnv, but Phase 1 requires publishing the new key while continuing to
sign with the old key so cached JWKS consumers don't fail; update the test
around setRotatingEnv, signJWT, getPrivateJwks, verifyJWT and
jose.decodeProtectedHeader to assert that after calling setRotatingEnv the new
JWKS entry exists (getPrivateJwks contains the new kid) while the freshly signed
JWT still carries the previous (old) kid and verifies successfully; then
separately simulate the post-cache-refresh path (or trigger the
rotate-to-new-signing step) and assert that subsequent signJWT tokens use the
new kid. Ensure you reference setRotatingEnv, signJWT, verifyJWT, getPrivateJwks
and jose.decodeProtectedHeader when making these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b8566ee-0841-4178-a679-3b9e84c34cab

📥 Commits

Reviewing files that changed from the base of the PR and between e30dbd3 and aab8b3d.

📒 Files selected for processing (2)
  • apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts
  • packages/stack-shared/src/utils/jwt.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts

Comment on lines +113 to +116
const mintedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-3" } });
const header = jose.decodeProtectedHeader(mintedJwt);
expect(preRotationKids.has(header.kid as string)).toBe(false);
await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: mintedJwt })).resolves.toBeTruthy();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd packages/stack-shared && find . -name "jwt.test.ts" -type f

Repository: stack-auth/stack-auth

Length of output: 88


🏁 Script executed:

cat -n packages/stack-shared/src/utils/jwt.test.ts | sed -n '105,250p'

Repository: stack-auth/stack-auth

Length of output: 7163


🏁 Script executed:

head -50 packages/stack-shared/src/utils/jwt.test.ts | cat -n

Repository: stack-auth/stack-auth

Length of output: 2562


🏁 Script executed:

cat -n packages/stack-shared/src/utils/jwt.ts | head -150

Repository: stack-auth/stack-auth

Length of output: 135


🏁 Script executed:

find packages/stack-shared -name "jwt.ts" -type f

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

grep -r "export.*signJWT" packages/stack-shared/src --include="*.ts"

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

find packages/stack-shared -type f -name "*.ts" | head -20

Repository: stack-auth/stack-auth

Length of output: 1057


🏁 Script executed:

grep -r "function signJWT\|const signJWT\|export.*signJWT" packages --include="*.ts" | grep -v test

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

rg "signJWT\s*[:=]" packages --type ts | head -20

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

rg "signJWT" packages/stack-shared/src/utils/jwt.test.ts -A 2 | head -5

Repository: stack-auth/stack-auth

Length of output: 333


🏁 Script executed:

ls -la packages/stack-shared/src/utils/

Repository: stack-auth/stack-auth

Length of output: 4237


🏁 Script executed:

cat -n packages/stack-shared/src/utils/jwt.tsx

Repository: stack-auth/stack-auth

Length of output: 8193


🏁 Script executed:

cat -n packages/stack-shared/src/utils/jwt.tsx | sed -n '56,72p'

Repository: stack-auth/stack-auth

Length of output: 727


Remove type casts that bypass safety checks.

The as string casts on header.kid allow undefined values to pass through, which would cause Set.has(undefined) to incorrectly return false and mask missing kid values. Similarly, the double cast on line 236 unnecessarily bypasses the type system. Use explicit null checks and .toHaveProperty() instead:

  • Lines 115 & 144: Add if (header.kid == null) throw new Error(...) before assertions
  • Line 236: Replace the double cast with expect(k).not.toHaveProperty("d")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/utils/jwt.test.ts` around lines 113 - 116, The test
is bypassing type safety by casting header.kid (and another double-cast at
variable k); update the tests to remove the "as string" and double-casts: after
decoding the JWT header in the signJWT flow (variable header from
jose.decodeProtectedHeader) add an explicit null/undefined guard like if
(header.kid == null) throw new Error(...) before calling preRotationKids.has or
other assertions, and replace the double-cast assertion on k with
expect(k).not.toHaveProperty("d") so the type system is respected; keep the
existing assertions around verifyJWT unchanged.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
docker/server/.env.example (1)

9-10: Clarify the comment to specify what to remove and when.

The comment "Remove after the grace window" is ambiguous—it doesn't specify what to remove (just STACK_SERVER_SECRET_OLD, or both lines 9-10?) or the grace window duration. Given the PR description mentions a ~1 day transition period and the reviewer's finding that getOldStackServerSecret returns an empty string (not null) when unset, it's critical to completely remove the line rather than leaving an empty value.

📝 Proposed clarification
-# Remove after the grace window
-STACK_SERVER_SECRET_OLD=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
+# TODO next-release: Remove STACK_SERVER_SECRET_OLD after the ~1 day grace period
+STACK_SERVER_SECRET_OLD=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo

This makes it clear that:

  • The entire STACK_SERVER_SECRET_OLD line should be removed (not just emptied)
  • The approximate grace window duration is ~1 day
  • The removal is tracked as a next-release cleanup item

Based on learnings, the codebase uses // TODO next-release to mark temporary backward-compatibility shims for single-release cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/server/.env.example` around lines 9 - 10, Update the ambiguous comment
above STACK_SERVER_SECRET_OLD to explicitly state that the entire
STACK_SERVER_SECRET_OLD line should be removed (not just emptied), specify the
grace window (~1 day), and mark it for follow-up by adding the project’s cleanup
marker (e.g., "// TODO next-release"); ensure this matches the runtime behavior
of getOldStackServerSecret which returns an empty string when unset so the line
must be deleted in the next release rather than left present with no value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@docker/server/.env.example`:
- Around line 9-10: Update the ambiguous comment above STACK_SERVER_SECRET_OLD
to explicitly state that the entire STACK_SERVER_SECRET_OLD line should be
removed (not just emptied), specify the grace window (~1 day), and mark it for
follow-up by adding the project’s cleanup marker (e.g., "// TODO next-release");
ensure this matches the runtime behavior of getOldStackServerSecret which
returns an empty string when unset so the line must be deleted in the next
release rather than left present with no value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56d12f13-358b-469d-9593-f02a5a09eee4

📥 Commits

Reviewing files that changed from the base of the PR and between aab8b3d and c9b4643.

📒 Files selected for processing (1)
  • docker/server/.env.example

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants