# Next.js auth

> Sign in, verify links and codes, OAuth with PKCE, MFA and sign-out from Server Actions, with the session visible in the same request.

Source: https://bettersupabase.com/docs/frameworks/next-auth

`createNext` signs users in and out on the server. Each helper makes the Auth
calls it needs, then commits the result once: it verifies the new token,
writes or clears the session cookies through the request's cookie jar, stores
the verified session for the next render, and makes `getRequestSession()`,
`session()`, `context()` and `cached()` return the new session for the rest of
the request. A render that never signs in or out still verifies its token
once.

| Helper                              | Auth call                                       |
| ----------------------------------- | ----------------------------------------------- |
| `signIn` (`signInWithPassword`)     | `POST /auth/v1/token?grant_type=password`       |
| `verifyOtp`                         | `POST /auth/v1/verify`                          |
| `authorizeUrl`, then `exchangeCode` | `/auth/v1/authorize`, then `grant_type=pkce`    |
| `verifyMfa`                         | `POST /auth/v1/factors/:id/challenge`, `verify` |
| `signOut`                           | `POST /auth/v1/logout`, then the cookies        |

Every helper except `authorizeUrl` returns an `ActionResult<SignedIn>`,
plain data that a Server Action can return to a client form as it is:

```ts
interface SignedIn {
  redirectTo: string; // `next` when it is a same-origin path, else `fallback`
  mfa: boolean; // a verified factor and the session is not aal2 yet
}
```

The committed session never leaves the server: read it with
`bsb.getRequestSession()` or `bsb.context()` later in the same action.

Errors are `unauthorized` with Auth's `error_code` as `code`
(`invalid_credentials`, `otp_expired`, `code_verifier_missing`),
`rate_limited`, `network` or `timeout`. Auth's own message never reaches the
result, so a form can't tell whether an account exists.

## One URL [#one-url]

The session cookie name comes from the project URL
(`sb-<project-ref>-auth-token`), so the server and the browser client must
derive it from the same URL. Pass the validated env to `createNext` and read
the browser values from it:

```ts title="src/lib/supabase/server.ts"
import "server-only";
import { loadEnv } from "better-supabase/env";
import { createNext } from "better-supabase/next";
import { betterSupabase } from "./index";

export const env = loadEnv();
export const bsb = createNext(betterSupabase, { env });
```

Doctor's [BS506](/docs/cli/doctor#bs506) warns when an env file sets
`SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_URL` to different URLs. When the
split is intended (a custom domain in the browser, the project URL on the
server), set `auth.cookie.name` on `createNext` and on the browser client to
the same name. See [Environment](/docs/auth/env).

## Password sign-in [#password-sign-in]

The recommended pattern is a Server Action that calls the helper, then
`redirect(redirectTo)`. The cookies ride on the redirect response, and the
next page renders signed in.

```ts title="src/features/auth/auth-actions.ts"
"use server";
import { redirect } from "next/navigation";
import { bsb } from "@/lib/supabase/server";

export async function signIn(
  _state: string | null,
  form: FormData,
): Promise<string | null> {
  const result = await bsb.signIn({
    email: String(form.get("email")),
    password: String(form.get("password")),
    next: String(form.get("next") ?? ""),
  });

  if (!result.ok) {
    return result.error.code ?? result.error.kind;
  }

  const { redirectTo, mfa } = result.data;
  redirect(mfa ? `/mfa?next=${encodeURIComponent(redirectTo)}` : redirectTo);
}
```

When the landing page depends on who signed in (a default organization, an
onboarding step), read it in the same action. `bsb.context()` already sees the
new session:

```ts
const ctx = await bsb.context();
// landingPath is your own lookup, such as the user's first organization.
redirect(await landingPath(ctx));
```

## Email links and codes [#email-links-and-codes]

`verifyOtp` takes a link's `token_hash` or an email and the 6-digit code.
`type` is `signup`, `invite`, `magiclink`, `recovery`, `email_change` or
`email` (codes). The new session replaces any session the request carried.

```ts title="src/app/auth/confirm/route.ts"
import type { EmailOtpType } from "better-supabase/next";
import { redirect } from "next/navigation";
import { bsb } from "@/lib/supabase/server";

export async function GET(request: Request): Promise<never> {
  const params = new URL(request.url).searchParams;
  const result = await bsb.verifyOtp({
    tokenHash: params.get("token_hash") ?? "",
    type: (params.get("type") ?? "email") as EmailOtpType,
    next: params.get("next"),
  });

  redirect(result.ok ? result.data.redirectTo : "/sign-in?error=link");
}
```

Point the email templates at this route with
`{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink`.
For a code, call `verifyOtp({ type: "email", email, token, next })` from the
form's action.

## OAuth with PKCE [#oauth-with-pkce]

`authorizeUrl` writes the PKCE verifier cookie (httpOnly, `SameSite=Lax`, 10
minutes) and returns the provider URL. `redirectTo` is the callback route and
must be in the project's redirect allow list; a safe `next` rides on it as
`?next=`.

```ts title="src/features/auth/auth-actions.ts"
export async function signInWithGitHub(next: string | null): Promise<never> {
  const { url } = await bsb.authorizeUrl({
    provider: "github",
    redirectTo: `${process.env.APP_URL}/auth/callback`,
    next,
  });

  redirect(url);
}
```

The callback exchanges the code. The verifier cookie is deleted whether the
exchange succeeds or not, so a code works once:

```ts title="src/app/auth/callback/route.ts"
export async function GET(request: Request): Promise<never> {
  const params = new URL(request.url).searchParams;
  const result = await bsb.exchangeCode({
    code: params.get("code") ?? "",
    next: params.get("next"),
  });

  redirect(result.ok ? result.data.redirectTo : "/sign-in?error=oauth");
}
```

## MFA [#mfa]

`verifyMfa` challenges the factor and verifies the code as the signed-in
user, then commits the `aal2` session; `mfa` is then false.

```ts
export async function verifyCode(factorId: string, code: string, next: string) {
  const result = await bsb.verifyMfa({ factorId, code, next });
  if (result.ok) redirect(result.data.redirectTo);
  return result.error.code ?? result.error.kind;
}
```

`mfa: true` after a sign-in is advisory. Keep the proxy's `protect` and
`action({ aal: 2 })` on pages and actions that need the second factor, see
[MFA and SSO](/docs/auth/mfa-sso).

## Sign-out [#sign-out]

`signOut` ends the session in Auth first, then clears the session cookies,
forgets the user's verified sessions and expires the user's cache tags. When
Auth can't be reached the cookies stay and the result carries the error, so a
session Auth still considers live is never dropped on this side alone; show
the error and let the user retry.

```ts
export async function signOut(): Promise<never> {
  await bsb.signOut({ next: "/" });
  redirect("/");
}
```

`scope` is `local` (this session, the default), `global` (every session of
the user) or `others` (every other session; this one keeps its cookies). The
other sessions' tokens stay valid until they expire, so every scope forgets
the user's verified sessions and expires their cache tags; the kept session
verifies its token once more on the next render. `cookieScopes` on
`createNext` also expires the cookie at the domains and paths earlier deploys
wrote it at, see
[Sessions](/docs/auth/sessions#moving-the-cookie-to-another-domain-or-path).

## Flows that start no session [#flows-that-start-no-session]

Sending a magic link or a code, a password reset email, `signUp` with email
confirmation and resending an email start no session, so they stay on the
supabase-js client. After `updateUser({ password })`, call
`bsb.invalidateSession(userId)`.

## Reads in the same request [#reads-in-the-same-request]

Reads after a commit see the committed session:

```ts
await bsb.getRequestSession(); // null
await bsb.signIn({ email, password });
await bsb.getRequestSession(); // the new user
await bsb.context({ tenant }); // a context for the new user and tenant
```

Each commit bumps a request generation that keys the reads React's `cache()`
holds per request, so they miss once and then answer from the committed
session without verifying the token again. The reads also rebuild the
request's `cookie` header from Next's cookie jar, which already holds the
commit's writes, so they see the new session where that cache doesn't apply
(a route handler, or a Server Action depending on the Next.js version). A
`tenant` resolver on `createNext` that parses another cookie from
`request.headers` sees that rebuilt header, with each value percent-encoded.