Skip to content

feat(auth): auth.resend() consistent confirmation flow#2144

Open
weilirs wants to merge 4 commits intosupabase:masterfrom
weilirs:auth.resend-consistent-flow
Open

feat(auth): auth.resend() consistent confirmation flow#2144
weilirs wants to merge 4 commits intosupabase:masterfrom
weilirs:auth.resend-consistent-flow

Conversation

@weilirs
Copy link
Copy Markdown

@weilirs weilirs commented Feb 28, 2026

🔍 Description

What changed?

When flowType is 'pkce', resend() now generates a fresh code_verifier/code_challenge pair (via the existing getCodeChallengeAndMethod helper), stores the verifier, and includes code_challenge + code_challenge_method in the request body to /resend. This follows the exact same pattern already used by signUp() and signInWithOtp(). See this PR

  • packages/core/auth-js/src/GoTrueClient.ts — Added PKCE challenge generation in the email branch of resend(), included code_challenge and code_challenge_method in the request body, and added code-verifier cleanup on both HTTP-error returns and in the catch block.
  • packages/core/auth-js/test/GoTrueClient.test.ts — Added tests for resend() with PKCE flowType covering both type: 'signup' and type: 'email_change', plus assertions that code_challenge is included in the request body and that the code verifier is cleaned up on HTTP errors.

Supported resend types with PKCE: Both signup and email_change go through the if ('email' in credentials) branch and will include PKCE parameters when flowType is 'pkce'. The auth server PR supabase/auth#2401 supports PKCE for both types.

No changes to the phone/SMS resend path, user-facing types, or implicit flow behavior.

Why was this change needed?

The GoTrue server has been updated to accept code_challenge and code_challenge_method on the /resend endpoint. Previously, resend() always produced implicit-flow confirmation links (#access_token=...), even when the original signUp() used PKCE. This forced developers using server-side frameworks (e.g., Next.js App Router) to implement workarounds for handling hash fragments that aren't accessible in server route handlers.

With this change, resend confirmation emails use ?code=... (PKCE) when the SDK is configured for PKCE flow, consistent with the initial signup email. This applies to both signup confirmation resends and email change confirmation resends.

Closes supabase/supabase#42527

📸 Screenshots/Examples

Before (implicit flow on resend — hash fragment not accessible server-side):

https://example.com/auth/confirm#access_token=xxx&refresh_token=yyy

After (PKCE flow on resend — query param works with server routes):

https://example.com/auth/confirm?code=xxx

🔄 Breaking changes

  • This PR contains no breaking changes

📋 Checklist

  • I have read the Contributing Guidelines
  • My PR title follows the conventional commit format: feat(auth-js): add PKCE support to resend()
  • I have run npx nx format to ensure consistent code formatting
  • I have added tests for new functionality (if applicable)
  • I have updated documentation (if applicable)

📝 Additional notes

  • Each resend() call generates a fresh code_verifier/code_challenge pair — it does not reuse the pair from the original signUp(), since the user may be in a different browser session.
  • The user-facing ResendParams type is unchanged; PKCE params are added internally by the SDK based on the configured flowType, matching how signUp() and signInWithOtp() already work.
  • SMS-based resend types (sms, phone_change) are unaffected.
  • On HTTP errors (e.g., 400 for invalid email), the code verifier is now cleaned up from storage immediately, consistent with how signUp() handles it — rather than leaving it orphaned until the next call.

@weilirs weilirs requested review from a team as code owners February 28, 2026 13:33
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e6bdfe2 and f976688.

📒 Files selected for processing (2)
  • packages/core/auth-js/src/GoTrueClient.ts
  • packages/core/auth-js/test/GoTrueClient.test.ts

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added PKCE (Proof Key for Code Exchange) flow support to the email resend functionality, enabling enhanced security for authentication requests.
  • Tests

    • Added test coverage for email resend with PKCE flow.

Walkthrough

This PR adds PKCE flow support to the resend() method in GoTrueClient. When the PKCE flow type is detected, the code retrieves the code_verifier and code_challenge from browser storage and includes code_challenge and code_challenge_method parameters in the resend request body. Error handling is also enhanced to clean up the stored code-verifier entry on failure. A new test case validates that PKCE-enabled resend works correctly for email authentication scenarios.

Assessment against linked issues

Objective Addressed Explanation
Add PKCE flow support to resend() method [#42527]
Include code_challenge and code_challenge_method in resend requests when PKCE is enabled [#42527]
Clean up stored code-verifier on resend errors [#42527]

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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 2, 2026

Open in StackBlitz

@supabase/auth-js

npm i https://pkg.pr.new/@supabase/auth-js@2144

@supabase/functions-js

npm i https://pkg.pr.new/@supabase/functions-js@2144

@supabase/postgrest-js

npm i https://pkg.pr.new/@supabase/postgrest-js@2144

@supabase/realtime-js

npm i https://pkg.pr.new/@supabase/realtime-js@2144

@supabase/storage-js

npm i https://pkg.pr.new/@supabase/storage-js@2144

@supabase/supabase-js

npm i https://pkg.pr.new/@supabase/supabase-js@2144

commit: 1bc0eda

@mandarini
Copy link
Copy Markdown
Contributor

Hi @weilirs ! Thank you for this PR! As you already wrote in the description, we need to wait for the auth PR to be merged/released first. While we wait, can you please make these improvements?

  1. Add a test for type: 'email_change', the if ('email' in credentials) branch handles both signup and email_change. The auth server PR also supports PKCE for email_change. But the only test added is for type: 'signup'.

  2. Can you pelase strengthen the test assertion? The current test only checks error === null against a mock. It doesn't verify that getCodeChallengeAndMethod was actually called or that code_challenge was included in the request.

  3. Can you also please clarify the error-path cleanup? In the email branch, if _request returns an HTTP error (e.g., 400 for wrong email), the code-verifier written by getCodeChallengeAndMethod is left in storage, and cleanup only runs in the catch block, not on HTTP-error returns. This is consistent with signInWithOtp (line 1257), but I think it would be worth it to either:

  • Adding a comment like // code-verifier cleanup follows signInWithOtp pattern cleaned up on next call, or
  • Making it consistent with signUp by adding if (error) await removeItemAsync(...) before the return
  1. Update the PR description, since now only mentions signup. Since the implementation also covers email_change, update the description to say so explicitly. The auth server PR #2401 supports both, and both should be documented I believe.

Oh and finally can you please use conventional commits? I assumed you pushed with --no-verify 🫣 ? You can use npm run commit to help you!

Thank you so much for this contribution! :D Let's wait for the auth team now to check the other PR first!

@mandarini mandarini self-assigned this Mar 9, 2026
@mandarini mandarini added the do-not-merge Do not merge this PR. label Mar 9, 2026
@mandarini
Copy link
Copy Markdown
Contributor

Adding do not merge until supabase/auth#2401 this PR is merged.

Copy link
Copy Markdown
Contributor

@mandarini mandarini left a comment

Choose a reason for hiding this comment

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

Please read my comment above

@mandarini mandarini added the needs server code This feature/fix needs changes on the server side, before the client side changes can take effect. label Mar 10, 2026
Copilot AI review requested due to automatic review settings March 29, 2026 08:33
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

Aligns auth.resend() with the SDK’s existing PKCE email confirmation behavior by generating a fresh PKCE challenge on resend when flowType === 'pkce', enabling confirmation links that use ?code=... instead of hash-fragment tokens.

Changes:

  • Generate/store a new PKCE code_verifier and send code_challenge + code_challenge_method in the email /resend request when flowType is pkce.
  • Add error-path cleanup for the stored code_verifier during resend().
  • Add a test case covering resend() with PKCE flowType.

Reviewed changes

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

File Description
packages/core/auth-js/src/GoTrueClient.ts Adds PKCE challenge generation and request fields to the email resend flow; adds code-verifier cleanup on error.
packages/core/auth-js/test/GoTrueClient.test.ts Adds a PKCE resend test case.

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

Comment on lines +2483 to 2499
let codeChallenge: string | null = null
let codeChallengeMethod: string | null = null
if (this.flowType === 'pkce') {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body: {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

In the non-PKCE case, code_challenge / code_challenge_method are still being included in the /resend request body as explicit null values. This changes the payload compared to prior versions and can trigger backend validation differences vs omitting the fields entirely. Consider only adding these properties to the body when this.flowType === 'pkce' (e.g., via a conditional object spread) so implicit-flow resend requests remain unchanged.

Suggested change
let codeChallenge: string | null = null
let codeChallengeMethod: string | null = null
if (this.flowType === 'pkce') {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body: {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
const body: {
email: string
type: typeof type
gotrue_meta_security: { captcha_token: string | undefined }
code_challenge?: string | null
code_challenge_method?: string | null
} = {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
}
if (this.flowType === 'pkce') {
const [codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
body.code_challenge = codeChallenge
body.code_challenge_method = codeChallengeMethod
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body,

Copilot uses AI. Check for mistakes.
Comment on lines +610 to +615
const { error } = await pkceClient.resend({
email,
type: 'signup',
options: { emailRedirectTo: 'http://localhost:9999/welcome' },
})
expect(error).toBeNull()
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

This test only asserts that pkceClient.resend() returns error === null, but it doesn’t verify the new PKCE-specific behavior (generating/storing a fresh code_verifier and sending code_challenge + code_challenge_method). As written, it could still pass even if those fields aren’t included. Consider using a client with a mocked fetch to assert the request body contains the PKCE fields (and/or assert ${storageKey}-code-verifier is set after the call).

Suggested change
const { error } = await pkceClient.resend({
email,
type: 'signup',
options: { emailRedirectTo: 'http://localhost:9999/welcome' },
})
expect(error).toBeNull()
const fetchSpy = jest
.spyOn(globalThis as any, 'fetch')
.mockImplementation(async (_input: any, init?: any) => {
if (init && typeof init.body === 'string') {
const body = JSON.parse(init.body)
expect(body.code_challenge).toEqual(expect.any(String))
expect(body.code_challenge_method).toBe('S256')
}
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({}),
}) as any
})
try {
const { error } = await pkceClient.resend({
email,
type: 'signup',
options: { emailRedirectTo: 'http://localhost:9999/welcome' },
})
expect(error).toBeNull()
const storedCodeVerifier = await memoryLocalStorageAdapter.getItem(
`${STORAGE_KEY}-code-verifier`
)
expect(storedCodeVerifier).toEqual(expect.any(String))
} finally {
fetchSpy.mockRestore()
}

Copilot uses AI. Check for mistakes.
@weilirs
Copy link
Copy Markdown
Author

weilirs commented Mar 29, 2026

Hi @mandarini, sorry for the late reply but I've made the changes you suggested. Thanks!

Copy link
Copy Markdown
Contributor

@mandarini mandarini left a comment

Choose a reason for hiding this comment

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

Hi @weilirs, thank you so much for making those changes! 💚

The tests are much stronger now, checking the actual request body for code_challenge and code_challenge_method is exactly what we needed, and the error-path cleanup test is solid. The email_change coverage is also a great addition.

A couple of small things before we can merge:

  1. The email_change test doesn't verify that the code verifier was actually stored after the call. The signup test does this:

    const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
    expect(codeVerifier).not.toBeNull()

    It would be good to add the same assertion to the email_change test for consistency.

  2. There's no test for the implicit flow (non-PKCE) path. Since the body now always includes code_challenge and code_challenge_method (as null when not using PKCE), a test confirming that those fields are null in the body when flowType is not 'pkce' would make the behavior explicit and protect against regressions.

We're still blocked on supabase/auth#2401 merging before we can ship this, so there's no rush, but it would be great to have these in while we wait.

Thank you again for sticking with this and addressing the feedback so thoroughly!

@mandarini mandarini changed the title auth.resend() consistent confirmation flow feat(auth): auth.resend() consistent confirmation flow Mar 30, 2026
@weilirs
Copy link
Copy Markdown
Author

weilirs commented Apr 1, 2026

Hi @weilirs, thank you so much for making those changes! 💚

The tests are much stronger now, checking the actual request body for code_challenge and code_challenge_method is exactly what we needed, and the error-path cleanup test is solid. The email_change coverage is also a great addition.

A couple of small things before we can merge:

  1. The email_change test doesn't verify that the code verifier was actually stored after the call. The signup test does this:

    const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
    expect(codeVerifier).not.toBeNull()

    It would be good to add the same assertion to the email_change test for consistency.

  2. There's no test for the implicit flow (non-PKCE) path. Since the body now always includes code_challenge and code_challenge_method (as null when not using PKCE), a test confirming that those fields are null in the body when flowType is not 'pkce' would make the behavior explicit and protect against regressions.

We're still blocked on supabase/auth#2401 merging before we can ship this, so there's no rush, but it would be great to have these in while we wait.

Thank you again for sticking with this and addressing the feedback so thoroughly!

Hey @mandarini ! I strengthened the tests per your request, please take a look.

@mandarini
Copy link
Copy Markdown
Contributor

@weilirs thanks. Let's wait for the server PR now to be merged!

cemalkilic pushed a commit to supabase/auth that referenced this pull request Apr 2, 2026
## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

The `/resend` endpoint hardcodes `models.ImplicitFlow` for both `signup`
and `email_change` verification types
([#42527](supabase/supabase#42527)). This
means resent confirmation emails always use the implicit flow —
redirecting with tokens in the URL hash fragment (`#access_token=...`) —
even when the original `signUp()` used PKCE.

This creates an inconsistency where:
- Initial signup email: `https://example.com/auth/confirm?code=xxx`
(PKCE, works with server routes)
- Resent email: `https://example.com/auth/confirm#access_token=xxx`
(implicit, requires client-side handling)

Server-side route handlers (e.g., Next.js `route.ts`) cannot read hash
fragments, forcing developers to implement workarounds with client
components and dual flow handling.

Closes #42527

## What is the new behavior?

The `/resend` endpoint now accepts optional `code_challenge` and
`code_challenge_method` parameters for `signup` and `email_change`
types. When provided, the endpoint:

1. Determines the flow type from `code_challenge` (PKCE if present,
implicit if absent)
2. Creates a `FlowState` record for PKCE flows (needed by `/verify` to
issue an auth code)
3. Passes the correct flow type to `sendConfirmation` /
`sendEmailChange`

This produces confirmation emails with `?code=...` query params instead
of `#access_token=...` hash fragments, consistent with the initial
signup flow.

When `code_challenge` is not provided, behavior is **unchanged** —
implicit flow is used, maintaining full backward compatibility.

**Changes:**
- `internal/api/resend.go`: Added `CodeChallenge` and
`CodeChallengeMethod` fields to `ResendConfirmationParams`. Added PKCE
param validation for email-based types. Replaced hardcoded
`ImplicitFlow` with flow-aware logic for `signup` and `email_change`
cases.
- `internal/api/resend_test.go`: Added `TestResendPKCEValidation`
(invalid PKCE params return 400) and `TestResendPKCESuccess` (signup and
email change tokens get `pkce_` prefix when PKCE params are provided).

## Additional context

This is the server-side half of the fix. The JS SDK (`auth-js`) needs a
corresponding update to send `code_challenge` / `code_challenge_method`
in `resend()` calls when `flowType === 'pkce'`, following the same
pattern already used by `signUp()` and `signInWithOtp()`. See [this
PR](supabase/supabase-js#2144)

The implementation mirrors the existing PKCE pattern used across the
codebase (`signup.go`, `user.go`, `recover.go`, `magic_link.go`):
`getFlowFromChallenge` → conditional `generateFlowState` → pass
`flowType` to the email sender.
@mandarini
Copy link
Copy Markdown
Contributor

Auth changes are in. We're waiting for the new release, then we merge

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

Labels

do-not-merge Do not merge this PR. needs server code This feature/fix needs changes on the server side, before the client side changes can take effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth.resend() uses implicit flow instead of PKCE flow, causing inconsistent confirmation flows

3 participants