Skip to content

feat(auth): store PKCE verifiers per flow to survive overlapping flows - #1662

Open
spydon wants to merge 2 commits into
mainfrom
worktree-1648
Open

feat(auth): store PKCE verifiers per flow to survive overlapping flows#1662
spydon wants to merge 2 commits into
mainfrom
worktree-1648

Conversation

@spydon

@spydon spydon commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Each PKCE flow now keeps its code verifier in a slot of its own instead of a single fixed key that a later flow silently overwrote. Starting an OAuth sign-in while a password recovery was still pending used to break whichever of the two completed second.

Outcome: implemented

Closes #1648, SDK-1429.

What changed

  • Per-flow verifier slots (packages/gotrue/lib/src/pkce_verifier_store.dart, new). Verifiers are stored under supabase.auth.token-flow-<flowId>-code-verifier. At most five are kept, oldest evicted first, tracked in an index entry because GotrueAsyncStorage cannot enumerate keys. Flow ids are validated against a fixed shape before they are used to build a storage key, since they can arrive from a callback URL. Marked @internal.
  • OAuthResponse.flowId returns the id of the flow that getOAuthSignInUrl or getLinkIdentityUrl started, null on the implicit flow.
  • exchangeCodeForSession(authCode, {String? flowId}) picks that flow's verifier. A given flow id is looked up in its slot only, deliberately without falling back to the most recent verifier: submitting a mismatched verifier would spend the single-use auth code. Without a flow id the most recently started flow's verifier is used, so existing callers behave exactly as before.
  • getSessionFromUrl reads the reserved sb_flow_id query parameter off the callback URL, which is how supabase_flutter handles auth deep links, so the right verifier is picked without the app threading anything itself.
  • appendPkceFlowIdToRedirects on GoTrueClient, AuthClientOptions and FlutterAuthClientOptions appends sb_flow_id to redirectTo. It is the only way to tell concurrent email OTP, password recovery and email change flows apart, since those hand back no flow id. Opt-in and defaulting to false, because the auth server matches redirect URLs against the allow list including the query string, so an entry without a wildcard stops matching once the parameter is appended.
  • Sign-out clears every pending verifier rather than just the one fixed key, and sb_flow_id is stripped from the browser URL after a successful exchange on web.

The legacy supabase.auth.token-code-verifier key is still written and read as a fallback, so nothing on the existing API breaks.

Deviation from the reference

The issue text says signInWithOtp, signInWithSSO, resend, updateUser and resetPasswordForEmail also return a flow id. The reference implementation only adds it to the OAuth response, so this matches supabase-js as merged rather than as described.

Reference

supabase-js PR supabase/supabase-js#2569, commit 97b58eb428556d768ae982c511fa10c4b7b8119f.

Compliance matrix

auth.sign_in.exchange_code_for_session stays implemented, with GoTrueClient.appendPkceFlowIdToRedirects, AuthClientOptions.appendPkceFlowIdToRedirects and OAuthResponse.flowId registered under it.

Tests

37 new tests in packages/gotrue/test/pkce_flow_test.dart covering the slot ring and its eviction, the index tolerating malformed contents, flow id validation, redirect parameter construction, and client level behavior: two overlapping flows keeping separate verifiers, exchanging with and without a flow id, unknown and malformed flow ids failing fast, getSessionFromUrl picking the flow off the URL, and sign-out clearing everything.

Full gotrue (499), supabase (136) and supabase_flutter (63) suites pass locally against the local Supabase stack.

Summary by CodeRabbit

  • New Features

    • Added support for multiple concurrent PKCE authentication flows across OAuth, signup, recovery, and verification.
    • Added an option to include flow identifiers in authentication redirect URLs.
    • OAuth responses now include the associated flow identifier for session exchange.
    • Added configuration support across Dart, Flutter, and Supabase clients.
  • Bug Fixes

    • Improved handling of invalid, expired, reused, or missing PKCE flow identifiers.
    • Authentication URL cleanup now removes flow identifiers after processing.
    • Improved isolation and cleanup of pending authentication flows.

Each PKCE flow now keeps its code verifier in a slot of its own, keyed
`supabase.auth.token-flow-<flowId>-code-verifier`, instead of a single
fixed key that a later flow silently overwrote. At most five slots are
kept, oldest evicted first, tracked in an index entry since
GotrueAsyncStorage cannot enumerate keys.

getOAuthSignInUrl and getLinkIdentityUrl return the id of the flow they
started as OAuthResponse.flowId, and exchangeCodeForSession accepts it
to pick that flow's verifier. A given flow id is looked up in its slot
only: falling back to another flow's verifier would spend the single-use
auth code. Without a flow id the verifier of the most recently started
flow is used, so existing callers are unaffected.

Flows that hand back no flow id, such as email OTP, password recovery
and email change, can be told apart by enabling
appendPkceFlowIdToRedirects. It appends the reserved `sb_flow_id` query
parameter to the redirect URL, which round-trips through the auth server
and is read back by getSessionFromUrl, so deep link handling picks the
right verifier by itself. It is opt-in because the auth server matches
redirect URLs against the allow list including the query string.

SDK-1429
@spydon
spydon requested a review from a team as a code owner August 6, 2026 12:13
@github-actions github-actions Bot added the auth This issue or pull request is related to authentication label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f041a83-0b55-4284-82d5-16964c7d0037

📥 Commits

Reviewing files that changed from the base of the PR and between 67e0354 and f2cd265.

📒 Files selected for processing (1)
  • packages/gotrue/lib/src/pkce_verifier_store.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gotrue/lib/src/pkce_verifier_store.dart

📝 Walkthrough

Walkthrough

PKCE now supports up to five concurrent flow-scoped verifiers. Flow IDs can pass through redirects and OAuth responses, and code exchange can select a specific verifier. Supabase and Flutter options expose this behavior, with tests covering storage, redirects, exchanges, and cleanup.

Changes

Concurrent PKCE flow support

Layer / File(s) Summary
Verifier storage and redirect handling
packages/gotrue/lib/src/constants.dart, packages/gotrue/lib/src/pkce_verifier_store.dart, packages/gotrue/lib/src/helper.dart, packages/gotrue/lib/src/types/auth_response.dart
Adds flow ID constants, bounded verifier storage, legacy-key compatibility, redirect URL handling, and OAuthResponse.flowId.
GoTrue PKCE flow integration
packages/gotrue/lib/src/gotrue_client.dart
Uses flow-scoped verifiers across authentication flows, supports flow-aware code exchange, appends flow IDs to configured redirects, and clears all flows on sign-out.
Client option propagation and callback cleanup
packages/supabase/lib/src/supabase_client_options.dart, packages/supabase/lib/src/supabase_client.dart, packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart, packages/supabase_flutter/lib/src/clear_auth_url_parameters.dart, sdk-compliance.yaml
Propagates appendPkceFlowIdToRedirects, preserves it through Flutter option copies, removes sb_flow_id from cleaned URLs, and updates capability metadata.
PKCE flow validation tests
packages/gotrue/test/pkce_flow_test.dart
Tests concurrent storage, flow validation, redirect manipulation, code exchange behavior, callback selection, replay rejection, and cleanup.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: tr00d

🚥 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 clearly describes the primary change: per-flow PKCE verifier storage for overlapping authentication flows.
Linked Issues check ✅ Passed The changes implement concurrent PKCE flows, bounded eviction, flow IDs, backward-compatible exchange behavior, API updates, and comprehensive tests for issue #1648.
Out of Scope Changes check ✅ Passed The changes remain focused on concurrent PKCE support, related API propagation, URL cleanup, compliance metadata, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-1648

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.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (3)
packages/gotrue/test/pkce_flow_test.dart (1)

263-281: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a prefix-collision case.

The filter in appendPKCEFlowIdToRedirect keeps a pair when it is neither equal to sb_flow_id nor prefixed with sb_flow_id=. No test covers a parameter whose name merely starts with the reserved name, such as sb_flow_idx. A future change from startsWith('sb_flow_id=') to contains('sb_flow_id') would drop that caller parameter and no test would fail.

♻️ Proposed additional test
test('keeps a parameter whose name only shares the prefix', () {
  expect(
    appendPKCEFlowIdToRedirect(
      'https://example.com/callback?sb_flow_idx=1',
      'flow-one',
    ),
    'https://example.com/callback?sb_flow_idx=1&sb_flow_id=flow-one',
  );
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/gotrue/test/pkce_flow_test.dart` around lines 263 - 281, Add a test
for appendPKCEFlowIdToRedirect using a URL with the unrelated parameter
sb_flow_idx=1, and assert it is preserved while sb_flow_id=flow-one is appended.
This should guard against prefix-based filtering removing parameters whose names
only begin with sb_flow_id.
packages/gotrue/lib/src/gotrue_client.dart (1)

492-502: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider truncating the logged flow id.

flowId reaches this method from the callback URL through getSessionFromUrl, so its value is third-party input of unbounded length. Line 494 writes it into a log record without a length cap. A caller can push an arbitrarily long string, or newline characters, into the log output.

The value is not secret, so the impact is limited to log noise and possible log-line injection. A truncation to a fixed prefix keeps the message useful.

♻️ Proposed change
     final requestedFlowId = PKCEVerifierStore.validateFlowId(flowId);
     if (flowId != null && requestedFlowId == null) {
-      _log.warning('Ignoring malformed PKCE flow id: $flowId');
+      final truncated = flowId.length > 64 ? flowId.substring(0, 64) : flowId;
+      _log.warning('Ignoring malformed PKCE flow id: $truncated');
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/gotrue/lib/src/gotrue_client.dart` around lines 492 - 502, Update
the malformed-flow warning in the PKCE handling around
PKCEVerifierStore.validateFlowId to log only a bounded, sanitized prefix of
flowId. Preserve the warning context while preventing unbounded or
newline-containing callback input from being written directly to logs.
packages/gotrue/lib/src/helper.dart (1)

88-113: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider stripping a stale flow id from the fragment as well.

The function removes an existing sb_flow_id from the query only. It keeps the fragment untouched. A redirect URL that already carries sb_flow_id in its fragment therefore ends up with two values.

getSessionFromUrl replaces # with & or ? before parsing, so both copies land in queryParameters, and the fragment copy is parsed last. The stale value can then win.

This requires the app to place the reserved parameter in the fragment itself, so it is a hardening concern rather than an active defect. Documenting the limitation in the doc comment is enough if you prefer not to parse the fragment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/gotrue/lib/src/helper.dart` around lines 88 - 113, The redirect URL
cleanup in the flow-id construction should also remove any existing
Constants.pkceFlowIdParam from the fragment before appending the new value,
preventing duplicate parsed parameters; alternatively, document this fragment
limitation in the function’s doc comment if fragment parsing is intentionally
out of scope. Keep the existing query-parameter filtering and fragment
preservation behavior for all other parameters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/gotrue/lib/src/gotrue_client.dart`:
- Around line 492-502: Update the malformed-flow warning in the PKCE handling
around PKCEVerifierStore.validateFlowId to log only a bounded, sanitized prefix
of flowId. Preserve the warning context while preventing unbounded or
newline-containing callback input from being written directly to logs.

In `@packages/gotrue/lib/src/helper.dart`:
- Around line 88-113: The redirect URL cleanup in the flow-id construction
should also remove any existing Constants.pkceFlowIdParam from the fragment
before appending the new value, preventing duplicate parsed parameters;
alternatively, document this fragment limitation in the function’s doc comment
if fragment parsing is intentionally out of scope. Keep the existing
query-parameter filtering and fragment preservation behavior for all other
parameters.

In `@packages/gotrue/test/pkce_flow_test.dart`:
- Around line 263-281: Add a test for appendPKCEFlowIdToRedirect using a URL
with the unrelated parameter sb_flow_idx=1, and assert it is preserved while
sb_flow_id=flow-one is appended. This should guard against prefix-based
filtering removing parameters whose names only begin with sb_flow_id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ae0ddca-b83c-4f08-a814-9a740a0dee27

📥 Commits

Reviewing files that changed from the base of the PR and between e06f230 and 67e0354.

📒 Files selected for processing (11)
  • packages/gotrue/lib/src/constants.dart
  • packages/gotrue/lib/src/gotrue_client.dart
  • packages/gotrue/lib/src/helper.dart
  • packages/gotrue/lib/src/pkce_verifier_store.dart
  • packages/gotrue/lib/src/types/auth_response.dart
  • packages/gotrue/test/pkce_flow_test.dart
  • packages/supabase/lib/src/supabase_client.dart
  • packages/supabase/lib/src/supabase_client_options.dart
  • packages/supabase_flutter/lib/src/clear_auth_url_parameters.dart
  • packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart
  • sdk-compliance.yaml

Copilot AI left a comment

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.

Pull request overview

Adds concurrent PKCE flow support to the auth clients by storing PKCE code verifiers per-flow (instead of a single overwritable key) and introducing an optional sb_flow_id redirect parameter so callbacks can be matched to the correct pending flow.

Changes:

  • Introduces per-flow PKCE verifier slots with bounded eviction and legacy-key fallback for backwards compatibility.
  • Extends the OAuth URL response and PKCE exchange path to carry/use an optional flowId, including automatic selection from sb_flow_id in getSessionFromUrl.
  • Propagates an opt-in appendPkceFlowIdToRedirects flag through Supabase/Supabase Flutter options and strips sb_flow_id from browser URLs after auth handling.

Reviewed changes

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

Show a summary per file
File Description
sdk-compliance.yaml Registers new auth symbols for SDK parity/compliance tracking.
packages/supabase/lib/src/supabase_client.dart Threads appendPkceFlowIdToRedirects into the GoTrue client initialization.
packages/supabase/lib/src/supabase_client_options.dart Adds AuthClientOptions.appendPkceFlowIdToRedirects with docs/default.
packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart Exposes/pass-through/copyWith support for appendPkceFlowIdToRedirects.
packages/supabase_flutter/lib/src/clear_auth_url_parameters.dart Ensures sb_flow_id is removed from query/fragment after auth handling.
packages/gotrue/test/pkce_flow_test.dart Adds coverage for slot storage/eviction, flow id validation, redirect behavior, and overlapping flows.
packages/gotrue/lib/src/types/auth_response.dart Adds OAuthResponse.flowId for PKCE flow identification.
packages/gotrue/lib/src/pkce_verifier_store.dart New internal per-flow verifier store with bounded index + legacy mirroring.
packages/gotrue/lib/src/helper.dart Adds helper to append/replace sb_flow_id in redirect URLs safely.
packages/gotrue/lib/src/gotrue_client.dart Integrates the verifier store, flow-id-aware PKCE exchange, and redirect URL flow id appending.
packages/gotrue/lib/src/constants.dart Defines sb_flow_id parameter name and max concurrent flow constant.

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

Comment on lines +58 to +63
Future<List<String>> store({
required String flowId,
required String verifier,
}) async {
await _storage.setItem(key: _slotKey(flowId), value: verifier);

Comment on lines +492 to 506
final requestedFlowId = PKCEVerifierStore.validateFlowId(flowId);
if (flowId != null && requestedFlowId == null) {
_log.warning('Ignoring malformed PKCE flow id: $flowId');
}

// A flow id that was given but is unusable must not fall back to the
// verifier of the most recent flow, for the same reason a missing slot does
// not: spending the auth code on the wrong verifier loses it for good.
final codeVerifierRawString = flowId != null && requestedFlowId == null
? null
: await _pkceVerifierStore!.retrieve(flowId: requestedFlowId);

if (codeVerifierRawString == null) {
throw AuthException('Code verifier could not be found in local storage.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth This issue or pull request is related to authentication

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parity(auth): implement concurrent PKCE flow support (flow id) [from supabase-js]

3 participants