Skip to content

feat(session): support password rotation via Record<string, string>#1335

Draft
productdevbook wants to merge 1 commit intomainfrom
feat/session-password-rotation
Draft

feat(session): support password rotation via Record<string, string>#1335
productdevbook wants to merge 1 commit intomainfrom
feat/session-password-rotation

Conversation

@productdevbook
Copy link
Copy Markdown
Member

@productdevbook productdevbook commented Mar 15, 2026

Summary

Closes #1050

Enables session password rotation by allowing SessionConfig.password to accept Record<string, string> in addition to a plain string.

const session = await useSession(event, {
  password: {
    // "default" key matches sessions sealed with plain string password
    default: "old-password-at-least-32-chars-long!!",
    // first key is used for sealing new sessions
    v2: "new-password-at-least-32-chars-long!!",
  },
});

How it works

  • Sealing: Uses the first key in the record (with its ID embedded in the token)
  • Unsealing: iron-crypto already supports PasswordHash (Record of passwords keyed by ID) — it reads the passwordId from the sealed token and looks up the matching password
  • Backward compat: Sessions sealed with a plain string password use passwordId "default" internally, so the old password should use "default" as its key

Changes

  • SessionConfig.password: stringstring | Record<string, string>
  • sealSession: extracts first key from record as { id, secret } for sealing
  • unsealSession: passes record directly to unseal() (already supported)
  • ~10 lines of implementation change

Test plan

  • Password rotation test: seal with old string password, unseal with rotated Record
  • All existing session tests pass (14/14)
  • Typecheck clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced session password rotation capability. Users can now securely rotate credentials without interrupting service. The system supports multiple passwords and automatically handles unsealing of existing sessions with prior credentials, enabling seamless transitions between old and new passwords for enhanced security management.

SessionConfig.password now accepts Record<string, string> for password
rotation. The first key is used for sealing new sessions, all keys
are tried for unsealing (via iron-crypto's existing PasswordHash support).

Usage:
  useSession(event, {
    password: {
      default: "old-password-at-least-32-chars...",
      new: "new-password-at-least-32-chars...",
    },
  });

Sessions sealed with a plain string password use passwordId "default",
so the old password should be keyed as "default" for backward compat.

Closes #1050

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@productdevbook productdevbook requested a review from pi0 as a code owner March 15, 2026 05:39
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

Session password configuration is extended to support password rotation by allowing the password field to accept either a string or a map of password IDs to passwords. The seal operation derives a password from this configuration, while unseal operations can work with any password variant.

Changes

Cohort / File(s) Summary
Session Configuration
src/utils/session.ts
Widened SessionConfig.password type to string | Record<string, string> with documentation on rotation semantics. Updated sealSession to extract and use the first password entry when a map is provided.
Password Rotation Test
test/session.test.ts
Added comprehensive test case "supports password rotation" covering session creation with an old password, cookie capture, and data retrieval using a rotated password configuration.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A key, then two, then more they say,
Old passwords needn't fade away,
We rotate, seal, and unseal with grace,
Each password finds its proper place,
Sessions spin in safety's embrace! 🔄

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: supporting password rotation via Record<string, string> type for SessionConfig.password.
Linked Issues check ✅ Passed The implementation fulfills issue #1050 by allowing SessionConfig.password to accept Record<string, string> for rotation, using the first entry for sealing and all entries for unsealing.
Out of Scope Changes check ✅ Passed All changes directly address password rotation requirements: type widening, seal logic updates, and test coverage, with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/session-password-rotation
📝 Coding Plan
  • Generate coding plan for human review comments

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.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/h3@1335

commit: 375c3de

Copy link
Copy Markdown

@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)
src/utils/session.ts (1)

32-38: Documentation could clarify key ordering semantics.

The phrase "first key" relies on JavaScript's object property insertion order, which may not be immediately obvious to all users. Consider adding a brief note about this behavior.

📝 Suggested documentation improvement
   /**
    * Private key used to encrypt session tokens.
    *
    * For password rotation, pass a record of `{ id: password }` pairs.
-   * The first key is used for sealing new sessions, all keys are tried for unsealing.
+   * The first key (by insertion order) is used for sealing new sessions;
+   * all keys are tried for unsealing based on the embedded password ID.
    */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/session.ts` around lines 32 - 38, The documentation for the
password field in session.ts is ambiguous about which key is considered the
"first key" when a record is provided; clarify that JavaScript object insertion
order determines which key is used for sealing new sessions and that consumers
should insert the preferred sealing key first (or use an ordered structure if
they need explicit ordering). Update the comment on the password: string |
Record<string, string> field to note that insertion order of object keys (as per
ECMAScript spec) decides the first key used for sealing and that all keys will
be attempted for unsealing.
test/session.test.ts (1)

115-155: Test covers backward compatibility but not full rotation behavior.

The test correctly validates that sessions sealed with the old string password can be unsealed after adding a rotated password map. However, it doesn't verify that new sessions created with the rotated config are sealed using the new password (first key in the map).

Consider adding assertions that new sessions sealed with rotatedConfig embed the correct password ID, ensuring the rotation actually advances the sealing password.

💡 Suggested enhancement
// After line 154, add verification that new sessions use the first key:
const newCreateRes = await t.fetch("/rotate/read", {
  method: "POST", // would need a POST handler
  headers: { Cookie: oldCookie },
});
// Verify the new session is sealed with id "default" (first key in rotatedConfig)

Alternatively, consider reordering the map to { new: newPassword, default: oldPassword } to demonstrate that new sessions use newPassword while old sessions with passwordId "default" still unseal correctly.

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

In `@test/session.test.ts` around lines 115 - 155, Add a new route that creates a
session using the rotatedConfig (e.g., a POST "/rotate/new" handler that calls
useSession(event, rotatedConfig) and writes/returns the session), then in the
test perform a fetch to that route and assert the newly-set cookie/session is
sealed with the rotatedConfig's first password key (i.e., check the Set-Cookie
value or any session sealing metadata returned to contain the password id such
as "new" or "default"); if you want the new sessions to use the literal new
password, reorder rotatedConfig.password to put the "new" key first ({ new:
newPassword, default: oldPassword }) before creating/verifying the new session.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/utils/session.ts`:
- Around line 208-213: The code builds sealPassword from config.password and can
produce {id: undefined, secret: undefined} when config.password is an empty
object, causing seal(session, sealPassword, ...) to fail; add validation before
constructing sealPassword to ensure config.password is either a non-empty string
or an object with at least one key/value (or use Object.entries to extract a
single [id, secret] pair) and throw or return a clear error if it's
empty/invalid, then only call seal(session, sealPassword, ...) when the
validated id and secret are defined.

---

Nitpick comments:
In `@src/utils/session.ts`:
- Around line 32-38: The documentation for the password field in session.ts is
ambiguous about which key is considered the "first key" when a record is
provided; clarify that JavaScript object insertion order determines which key is
used for sealing new sessions and that consumers should insert the preferred
sealing key first (or use an ordered structure if they need explicit ordering).
Update the comment on the password: string | Record<string, string> field to
note that insertion order of object keys (as per ECMAScript spec) decides the
first key used for sealing and that all keys will be attempted for unsealing.

In `@test/session.test.ts`:
- Around line 115-155: Add a new route that creates a session using the
rotatedConfig (e.g., a POST "/rotate/new" handler that calls useSession(event,
rotatedConfig) and writes/returns the session), then in the test perform a fetch
to that route and assert the newly-set cookie/session is sealed with the
rotatedConfig's first password key (i.e., check the Set-Cookie value or any
session sealing metadata returned to contain the password id such as "new" or
"default"); if you want the new sessions to use the literal new password,
reorder rotatedConfig.password to put the "new" key first ({ new: newPassword,
default: oldPassword }) before creating/verifying the new session.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5ca8fc78-91f4-4bd9-a1bf-2acd17c24a54

📥 Commits

Reviewing files that changed from the base of the PR and between 10bc7ce and 375c3de.

📒 Files selected for processing (2)
  • src/utils/session.ts
  • test/session.test.ts

Comment on lines +208 to +213
const sealPassword =
typeof config.password === "string"
? config.password
: { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };

const sealed = await seal(session, sealPassword, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing validation for empty password record.

If config.password is an empty object {}, this code will create { id: undefined, secret: undefined } and pass it to seal(), which will fail at runtime when trying to use an undefined password.

🛡️ Proposed fix to add validation
 const sealPassword =
     typeof config.password === "string"
       ? config.password
-      : { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
+      : (() => {
+          const keys = Object.keys(config.password);
+          if (keys.length === 0) {
+            throw new Error("Password record cannot be empty");
+          }
+          return { id: keys[0], secret: config.password[keys[0]] };
+        })();

Alternatively, use a simpler approach with Object.entries:

 const sealPassword =
     typeof config.password === "string"
       ? config.password
-      : { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
+      : (() => {
+          const [[id, secret]] = Object.entries(config.password);
+          if (!id) throw new Error("Password record cannot be empty");
+          return { id, secret };
+        })();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sealPassword =
typeof config.password === "string"
? config.password
: { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
const sealed = await seal(session, sealPassword, {
const sealPassword =
typeof config.password === "string"
? config.password
: (() => {
const keys = Object.keys(config.password);
if (keys.length === 0) {
throw new Error("Password record cannot be empty");
}
return { id: keys[0], secret: config.password[keys[0]] };
})();
const sealed = await seal(session, sealPassword, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/session.ts` around lines 208 - 213, The code builds sealPassword
from config.password and can produce {id: undefined, secret: undefined} when
config.password is an empty object, causing seal(session, sealPassword, ...) to
fail; add validation before constructing sealPassword to ensure config.password
is either a non-empty string or an object with at least one key/value (or use
Object.entries to extract a single [id, secret] pair) and throw or return a
clear error if it's empty/invalid, then only call seal(session, sealPassword,
...) when the validated id and secret are defined.

const sealPassword =
typeof config.password === "string"
? config.password
: { id: Object.keys(config.password)[0], secret: Object.values(config.password)[0] };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we are only takin first!

@pi0 pi0 marked this pull request as draft March 15, 2026 10:06
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.

FR: Allow multiple unseal passwords for sessions

2 participants