Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# `USER_UPDATED` on email and phone change verification

**Since:** v3.0.0
**Action required by:** v3.0.0

`@supabase/auth-js` now emits `USER_UPDATED` on `onAuthStateChange` after a successful email-change or phone-change verification. Previously it emitted `SIGNED_IN`.

This aligns the client with how the auth server (`supabase/auth`) already classifies these flows: an email or phone change is recorded as a user modification (`UserModifiedAction`), not as a sign-in.

## What changed

For sessions established via an `email_change` or `phone_change` verification — whether through `verifyOtp({ type: 'email_change' | 'phone_change', ... })`, an implicit-grant redirect (`?type=email_change|phone_change`), or the PKCE `exchangeCodeForSession` flow — subscribers to `onAuthStateChange` now receive:

```ts
event: 'USER_UPDATED'
```

instead of the previous:

```ts
event: 'SIGNED_IN'
```

All other event types are unchanged:

- `recovery` → `PASSWORD_RECOVERY` (unchanged)
- `signup`, `invite`, `magiclink`, `email`, `sms`, etc. → `SIGNED_IN` (unchanged)

## Who is affected

Anyone with an `onAuthStateChange` listener that runs sign-in logic (analytics events, route redirects, welcome toasts, fresh-data fetches scoped to a new session) on `SIGNED_IN` and previously relied on it firing after an email or phone change.

If you do not currently handle the post-email-change or post-phone-change case explicitly, you may also miss the event entirely unless you add a handler for `USER_UPDATED`.

## What to do

Move email/phone change handling onto `USER_UPDATED`:

```ts
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
// first-login work: analytics, redirects, welcome UI
} else if (event === 'USER_UPDATED') {
// re-fetch profile, refresh email/phone display, etc.
// also fires after supabase.auth.updateUser() — same handler covers both
} else if (event === 'PASSWORD_RECOVERY') {
// show password reset UI
}
})
```

If your previous `SIGNED_IN` handler was doing profile-refresh work specifically to pick up the new email or phone, that work belongs in the `USER_UPDATED` branch now.

## Why

The auth server treats email and phone change verifications as user modifications, not sign-ins (see `internal/api/verify.go` — both `emailChangeVerify` and the `phone_change` branch of `smsVerify` write `UserModifiedAction` to the audit log). Emitting `SIGNED_IN` on the client diverged from the server's semantics and produced spurious sign-in events on what is fundamentally a profile mutation.

This change brings auth-js in line with the server and with the other Supabase SDKs (Flutter/Dart, Swift, Kotlin, Python), which are being updated to the same behavior.
21 changes: 17 additions & 4 deletions packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ export default class GoTrueClient {
setTimeout(async () => {
if (redirectType === 'recovery') {
await this._notifyAllSubscribers('PASSWORD_RECOVERY', session)
} else if (redirectType === 'email_change' || redirectType === 'phone_change') {
await this._notifyAllSubscribers('USER_UPDATED', session)
} else {
await this._notifyAllSubscribers('SIGNED_IN', session)
}
Expand Down Expand Up @@ -1973,7 +1975,11 @@ export default class GoTrueClient {
if (data.session) {
await this._saveSession(data.session)
await this._notifyAllSubscribers(
redirectType === 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN',
redirectType === 'recovery'
? 'PASSWORD_RECOVERY'
: redirectType === 'email_change' || redirectType === 'phone_change'
? 'USER_UPDATED'
: 'SIGNED_IN',
data.session
)
}
Expand Down Expand Up @@ -2249,6 +2255,7 @@ export default class GoTrueClient {
* 4. `email_change` – Used when verifying an OTP sent to a new email address during an email update process.
* - The verification type used should be determined based on the corresponding auth method called before `verifyOtp` to sign up / sign-in a user.
* - The `TokenHash` is contained in the [email templates](/docs/guides/auth/auth-email-templates) and can be used to sign in. You may wish to use the hash for the PKCE flow for Server Side Auth. Read [the Password-based Auth guide](/docs/guides/auth/passwords) for more details.
* - Events emitted on success: `recovery` → `PASSWORD_RECOVERY`; `email_change` and `phone_change` → `USER_UPDATED`; all other types → `SIGNED_IN`.
*
* @example Verify Signup One-Time Password (OTP)
* ```js
Expand Down Expand Up @@ -2401,7 +2408,11 @@ export default class GoTrueClient {
if (session?.access_token) {
await this._saveSession(session as Session)
await this._notifyAllSubscribers(
params.type == 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN',
params.type === 'recovery'
? 'PASSWORD_RECOVERY'
: params.type === 'email_change' || params.type === 'phone_change'
? 'USER_UPDATED'
: 'SIGNED_IN',
session
)
}
Expand Down Expand Up @@ -3333,7 +3344,8 @@ export default class GoTrueClient {
if (this.flowType === 'pkce' && attributes.email != null) {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
this.storageKey,
'email_change'
)
}

Expand Down Expand Up @@ -4054,6 +4066,7 @@ export default class GoTrueClient {
* - The frequency of this event is related to the JWT expiry limit configured on your project.
* - `USER_UPDATED`
* - Emitted each time the `supabase.auth.updateUser()` method finishes successfully. Listen to it to update your application's UI based on new profile information.
* - Also emitted after a successful email change or phone change verification (`email_change` / `phone_change` OTP types and the corresponding implicit-grant or PKCE callback). In earlier versions this was emitted as `SIGNED_IN`.
* - `PASSWORD_RECOVERY`
* - Emitted instead of the `SIGNED_IN` event when the user lands on a page that includes a password recovery link in the URL.
* - Use it to show a UI to the user where they can [reset their password](/docs/guides/auth/passwords#resetting-a-users-password-forgot-password).
Expand Down Expand Up @@ -4343,7 +4356,7 @@ export default class GoTrueClient {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey,
true // isPasswordRecovery
'recovery'
)
}
try {
Expand Down
18 changes: 15 additions & 3 deletions packages/core/auth-js/src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,27 @@ export async function generatePKCEChallenge(verifier: string) {
return btoa(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

/**
* Tags the stored PKCE verifier so that the post-redirect `_exchangeCodeForSession`
* step can recover the original flow type and emit the correct
* `onAuthStateChange` event:
* - `'recovery'` → `PASSWORD_RECOVERY`
* - `'email_change'` → `USER_UPDATED`
*
* Sign-in flows (signUp, signInWithOtp, signInWithOAuth, etc.) leave `flowType`
* undefined; those emit `SIGNED_IN`.
*/
export type PKCEStoredFlowType = 'recovery' | 'email_change'

export async function getCodeChallengeAndMethod(
storage: SupportedStorage,
storageKey: string,
isPasswordRecovery = false
flowType?: PKCEStoredFlowType
) {
const codeVerifier = generatePKCEVerifier()
let storedCodeVerifier = codeVerifier
if (isPasswordRecovery) {
storedCodeVerifier += '/recovery'
if (flowType) {
storedCodeVerifier += `/${flowType}`
}
await setItemAsync(storage, `${storageKey}-code-verifier`, storedCodeVerifier)
const codeChallenge = await generatePKCEChallenge(codeVerifier)
Expand Down
123 changes: 123 additions & 0 deletions packages/core/auth-js/test/GoTrueClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,69 @@ describe('GoTrueClient', () => {
expect(error?.message).toContain('@supabase/ssr')
expect(error?.code).toEqual('pkce_code_verifier_not_found')
})

test.each([
{ tag: 'email_change', expectedEvent: 'USER_UPDATED' },
{ tag: 'recovery', expectedEvent: 'PASSWORD_RECOVERY' },
{ tag: '', expectedEvent: 'SIGNED_IN' },
])(
'exchangeCodeForSession() emits $expectedEvent when verifier is tagged "$tag"',
async ({ tag, expectedEvent }) => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
token_type: 'bearer',
expires_in: 3600,
user: {
id: '11111111-1111-1111-1111-111111111111',
aud: 'authenticated',
role: 'authenticated',
email: 'example@email.com',
app_metadata: {},
user_metadata: {},
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
}),
})

const storage = memoryLocalStorageAdapter()
const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: true,
storage,
flowType: 'pkce',
fetch: mockFetch as unknown as typeof fetch,
})

// @ts-expect-error 'Allow access to protected storageKey'
const storageKey = client.storageKey
const storedVerifier = tag ? `mock-verifier/${tag}` : 'mock-verifier'
await storage.setItem(`${storageKey}-code-verifier`, storedVerifier)

const callback = jest.fn()
const {
data: { subscription },
} = client.onAuthStateChange(callback)

const { data, error } = await client.exchangeCodeForSession('mock-code')

expect(error).toBeNull()
expect(data.session).not.toBeNull()
expect(callback).toHaveBeenCalledWith(
expectedEvent,
expect.objectContaining({ access_token: 'mock-access-token' })
)

subscription?.unsubscribe()
}
)
})

describe('Email Auth', () => {
Expand Down Expand Up @@ -913,6 +976,66 @@ describe('GoTrueClient', () => {
expect(data.user).toBeNull()
expect(data.session).toBeNull()
})

test.each([
{ type: 'email_change' as const, expectedEvent: 'USER_UPDATED' },
{ type: 'phone_change' as const, expectedEvent: 'USER_UPDATED' },
{ type: 'recovery' as const, expectedEvent: 'PASSWORD_RECOVERY' },
{ type: 'magiclink' as const, expectedEvent: 'SIGNED_IN' },
])(
'verifyOtp({ type: $type }) emits $expectedEvent on success',
async ({ type, expectedEvent }) => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
token_type: 'bearer',
expires_in: 3600,
user: {
id: '11111111-1111-1111-1111-111111111111',
aud: 'authenticated',
role: 'authenticated',
email: 'example@email.com',
app_metadata: {},
user_metadata: {},
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
}),
})

const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: false,
storage: memoryLocalStorageAdapter(),
fetch: mockFetch as unknown as typeof fetch,
})

const callback = jest.fn()
const {
data: { subscription },
} = client.onAuthStateChange(callback)

const { data, error } = await client.verifyOtp({
token_hash: 'token-hash',
type,
})

expect(error).toBeNull()
expect(data.session).not.toBeNull()
expect(callback).toHaveBeenCalledWith(
expectedEvent,
expect.objectContaining({ access_token: 'mock-access-token' })
)

subscription?.unsubscribe()
}
)
})

describe('signInWithOtp', () => {
Expand Down
Loading