Skip to main content
ASoc
Tutorial

React Password Reset: The Redirect That Exists Because a Render Can't Set a Cookie

Two routes, two Server Actions, and one redirect that exists because a Server Component render cannot write a cookie. Plus the enumeration answer that must not vary.

The ASoc Team10 min read

A password reset in a React app on the App Router is two routes, two Server Actions, and one redirect that exists for a reason nobody documents: a Server Component render cannot write a cookie. The recovery link lands on a page that can't finish the job, so the page forwards the code to a Route Handler that can.

That detour is the part people hit and can't explain. Everything else in this flow is short.

The four steps, and where each one runs

StepWhere it runsWhat it produces
1. Ask for a resetServer Action on /forgot-passwordAn email, sent by the auth provider
2. Click the linkThe provider redirects to /reset-password?code=…A single-use PKCE code in the URL
3. Exchange the codeRoute Handler at /auth/callbackA session cookie
4. Set the new passwordServer Action on /reset-passwordAn updated credential

Steps 1 and 4 are ordinary forms. Steps 2 and 3 are where the design decisions live.

Step 1: the request, and the answer that must not vary

// src/lib/actions/auth.ts
export async function requestPasswordReset(
  _prev: AuthState,
  formData: FormData,
): Promise<AuthState> {
  // …email extracted and format-checked…
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${SITE_URL}/reset-password`,
  });
  // Don't reveal whether the account exists either way (generic message).
  if (error) {
    console.error("auth: resetPasswordForEmail error", error);
  }
  return {
    ok: true,
    message: "If an account exists for that email, a reset link is on its way.",
  };
}

Read the error branch again: it logs and then returns success anyway. That is deliberate, and it is the single most-skipped requirement in password-reset tutorials.

A reset form that says "no account found for that email" is an account-enumeration oracle. Anyone can post a list of addresses at it and learn which ones are registered — which is worth money on its own, and is the reconnaissance step before credential stuffing. The defence is that the response must be identical for a registered and an unregistered address: same message, same status, same shape. Ours returns one string, unconditionally.

Two details make that hold in practice:

  • The error is logged server-side, so you don't lose the ability to debug a genuinely broken mailer.
  • The only early return above it is a format check on the address itself, which reveals nothing about registration. Every syntactically valid address reaches the provider call, so a registered and an unregistered one take the same path.

Both /forgot-password and /reset-password also carry robots: { index: false, follow: false } in their metadata. They're transactional endpoints; there is no version of "a password reset page ranking" that you want.

Step 2 and 3: the redirect a render can't avoid

The recovery email links directly to /reset-password?code=…. That page is a Server Component. Its entire body is this:

// src/app/reset-password/page.tsx
export default async function ResetPasswordPage({ searchParams }) {
  const { code } = await searchParams;
  // The recovery email links here directly, but only a Route Handler or
  // Server Action can persist the session cookie from the PKCE code
  // exchange — a Server Component render cannot. Forward the code through
  // the shared callback route, which exchanges it and sends the user back.
  if (code) {
    redirect(`/auth/callback?code=${encodeURIComponent(code)}&next=/reset-password`);
  }
  return /* … the form … */;
}

Here's the constraint underneath it. Next.js allows cookie writes only where there is a response the framework is still composing — a Server Action or a Route Handler. During a Server Component render, the cookie store is read-only. Attempting the exchange in the page body would succeed against the auth provider, consume the single-use code, and then fail to persist anything: the user lands on a form with no session, submits it, and gets a generic error. The code is now spent, so retrying the link doesn't work either. That's the bug this redirect exists to prevent, and it presents as "the reset link works once and then never".

So the page hands the code to the Route Handler that can write a cookie:

// src/app/auth/callback/route.ts
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = safeNext(searchParams.get("next"));

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) redirect(`${origin}${next}`);
    console.error("auth/callback: exchangeCodeForSession error", error);
  }
  redirect(`${origin}/login?error=auth`);
}

One handler serves three flows — email confirmation after signup, Google OAuth, and password recovery — because all three end in the same operation: turn a code into a session cookie, then go where next says.

That next is attacker-influenced, so it gets validated rather than trusted:

const SAFE_NEXT_RE = /^\/(?![/\\])/;
export function safeNext(next: string | null | undefined): string {
  return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}

Only a same-origin relative path passes. The negative lookahead rejects //evil.example — which browsers treat as protocol-relative — and /\evil.example, because WHATWG URL parsing treats a leading backslash as a slash too. A regex that only checked "starts with /" would let both through. The redirect-guard side of this is covered in React protected routes.

Step 4: the update, and where the authorization comes from

export async function updatePassword(
  _prev: AuthState,
  formData: FormData,
): Promise<AuthState> {
  const password = String(formData.get("password") ?? "");
  if (password.length < MIN_PASSWORD_LENGTH) {
    return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` };
  }
  const supabase = await createClient();
  const { error } = await supabase.auth.updateUser({ password });
  if (error) {
    console.error("auth: updateUser error", error);
    return { ok: false, message: GENERIC_ERROR };
  }
  return { ok: true, message: "Password updated — redirecting to sign in…" };
}

Notice what this function never receives: a token, a user id, an email. It reads one field. The authorization is entirely the recovery session in the cookie, established in step 3 — which is why steps 2 and 3 have to work before this one is even reachable in a useful state.

That inversion is worth stating plainly, because a lot of hand-rolled implementations get it backwards: the form does not prove who you are; the cookie does. A form that carried a user_id or a reset token as a hidden input would be trusting client-supplied identity, and would need its own verification before touching anything. Reading only the new password means there is nothing in the request body worth forging. The general form of that rule — validate by allowlisting the fields you read — is in React form validation.

The length check runs here, on the server, and is mirrored as minLength={10} on the input for immediate feedback. Client-side is UX; this is the gate.

The client half: one component, shared by four pages

There is exactly one Client Component in this flow, and it isn't per-page:

// src/components/molecules/AuthCard.tsx
"use client";
const [state, formAction, pending] = useActionState(action, null);
const router = useRouter();

useEffect(() => {
  if (state?.ok && redirectTo) router.push(redirectTo);
}, [state, redirectTo, router]);

AuthCard takes a Server Action as a prop and wraps useActionState around it. Login, signup, forgot-password and reset-password all render it with different actions, different fields as children, and a different redirectTo/login in the reset case, so a user who just changed their password signs in with it.

No form library, no client-side validation schema, no state for the fields themselves. The three things the client actually contributes are the pending flag on the submit button, the status message, and the post-success navigation. Everything else is a plain <form> posting to the server. The wider case for that shape is in skipping React Hook Form.

What this flow deliberately doesn't do

  • No "current password" field. A recovery flow's whole premise is that the user doesn't have it. The email round-trip is the proof of ownership.
  • No custom token table. The provider issues and single-uses the code; adding our own would be a second source of truth to keep in sync and to get wrong.
  • No session invalidation of other devices. Worth knowing you're not getting it for free — if your threat model includes "the attacker already has a session", that's a separate call to make after the update succeeds.
  • No rate limiting in application code. The provider rate-limits recovery sends. If yours doesn't, that's the first thing to add, because step 1 is an unauthenticated endpoint that sends email.

Mistakes and how they show up

SymptomCauseFix
The reset link works once, then every retry failsThe PKCE code was exchanged during a Server Component render, which can't persist the resulting cookie — the code is spent, the session isn't storedForward the code to a Route Handler or Server Action and exchange it there
"Auth session missing" when submitting the new passwordSame root cause, one screen later: the form rendered without a recovery sessionConfirm the callback ran; the page should redirect through it before rendering the form
Attackers can enumerate which emails are registeredThe request form returns a different message for unknown addressesReturn one generic message in both branches, and don't short-circuit before the provider call
The reset page appears in a search indexNo robots directive on a transactional routeSet robots: { index: false, follow: false } in the page's metadata
A crafted ?next= sends users off-site after the exchangeThe redirect target was taken from the URL and trustedAllow only same-origin relative paths, rejecting //host and /\host explicitly
Password rules pass in the browser and break on submitValidation only exists as an input attributeEnforce the same rule in the Server Action; the attribute is feedback, not a gate
Users are still signed in elsewhere after resettingUpdating a password doesn't revoke other sessions by defaultRevoke them explicitly if your threat model needs it — this is a decision, not an oversight

Frequently asked questions

How do you implement a password reset in React? Not in React, in the layer beneath it. The four steps — request, email, code exchange, update — all run on the server; React contributes a pending state and a status message. Here that's two Server Actions, one Route Handler, and one shared Client Component of about 40 lines of logic.

Why does the reset page redirect to a callback route instead of handling the code itself? Because writing a cookie during a Server Component render isn't allowed — the cookie store is read-only there. The code exchange must happen where a response is still being composed: a Route Handler or a Server Action. Doing it in the page body burns the single-use code and stores nothing.

Should the reset form tell the user if the email isn't registered? No. That turns the endpoint into an account-enumeration oracle. Return the same message either way — ours is "If an account exists for that email, a reset link is on its way." — and log the real outcome server-side.

Do I need a separate reset-token table? Not if your auth provider issues recovery links, and adding one is usually a downgrade: you'd own expiry, single-use enforcement, and secure comparison, all of which are easy to get subtly wrong. The provider's code plus a session cookie already covers it.

Does the new-password form need to send the user's email or id? No, and it shouldn't. The recovery session in the cookie identifies the user. A form that carries identity as a hidden field is trusting the client with the one thing it must not be trusted with.

Templates in this post

ASoc Estate Admin is a real-estate management dashboard with agents, listings and workspace apps — a multi-role back office where the reset flow is the one unauthenticated surface every role shares. ASoc Pulse Admin ships five dashboards and a full store back office, the scale at which "revoke other sessions on reset" stops being optional. ASoc Vertex Admin is a sales-analytics dashboard inside a complete admin shell, including the auth screens this flow renders into.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the session posture the rest of this stack is built on — getClaims() rather than getSession(), and why the cookie is the source of truth — see auth in React.

Keep reading

Tutorial9 min read

React Images: 26 `<img>` Tags and Not One `import`

1,160 image files, zero imported ones. The import-vs-string-path choice is really a choice about who verifies the path — and what you have to build once the bundler stops.

Read more
Tutorial9 min read

React Protected Routes: What a Server-Rendered Guard Does Differently

React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.

Read more