# Overview

> One resolver for every caller, local verification, and refreshes only where they belong.

Source: https://bettersupabase.com/docs/auth

Every server entry point in better-supabase starts with the same question:
who is calling? `resolveAuth(request)` answers it in this order:

1. your own [`AuthResolver`s](#custom-credentials) (API keys, third-party auth);
2. `Authorization: Bearer <jwt>`, for APIs, MCP servers and mobile apps;
3. the `@supabase/ssr` session cookie, for browsers.

```ts
import { resolveAuth } from "better-supabase/server";
import { loadEnv } from "better-supabase/env";

const env = loadEnv();
const { auth } = await resolveAuth(request, { env });

switch (auth.kind) {
  case "user": // auth.user.id, auth.claims, auth.token
  case "service": // a secret key, when `secret: true`
  case "anon": // auth.reason: 'none' | 'expired' | 'signed_out' | 'refresh_failed'
  case "invalid": // auth.reason: 'token' (did not verify) | 'claims' (failed betterSupabase.claims) | 'actor' (malformed act chain): answer 401
}
```

## No needless work [#no-needless-work]

* A valid token costs **no network call**. It is verified locally against
  the project's JWKS (`<url>/auth/v1/.well-known/jwks.json`), which is
  fetched once and cached.
* Nothing is written while the token is valid: no cookie churn, no
  `Set-Cookie` on every response.
* A Bearer token that fails verification is `invalid`, never silently
  downgraded to anonymous.

## Anonymous users [#anonymous-users]

A user from `signInAnonymously()` is a `user` with `is_anonymous: true` in
the token, and `toSession(auth).anonymous` is `true`. Every adapter's guard
refuses these users with a 403 (`code: "ANONYMOUS_USER"`) unless `allow` lists
`'anonymous'`, so a guest session never reaches a route meant for accounts.
The `realtime-tables` SQL module sends them no change signals.

| `allow`                  | Full users | Anonymous sign-ins   | No session |
| ------------------------ | ---------- | -------------------- | ---------- |
| `['user']` (the default) | admitted   | 403 `ANONYMOUS_USER` | 401        |
| `['user', 'anon']`       | admitted   | 403 `ANONYMOUS_USER` | admitted   |
| `['user', 'anonymous']`  | admitted   | admitted             | 401        |
| `['anonymous']`          | 403        | admitted             | 401        |

`'anon'` is the caller without a session and never admits an anonymous
sign-in; until 0.5.1 it did. Draw the same line in your RLS policies: a
policy that should not serve a guest session checks
`(select (auth.jwt() ->> 'is_anonymous')::boolean) is not true`.

```ts
app.use("/cart/*", bs.middleware({ allow: ["user", "anonymous"] }));
```

> **Use asymmetric signing keys**
>
> Local verification needs asymmetric JWT signing keys (ES256 or RS256), whose
> public half is in the JWKS. Hosted projects use them by default; for the local
> stack, `better-supabase keys` creates `signing_keys_path` and `better-supabase
>   doctor` warns when it is missing. With a legacy HS256 secret there is no
> public key to check against. This is what lets the Next.js proxy, Server
> Components and `'use cache: private'` session reads stay free of network calls
> ([Cache Components](/docs/frameworks/next-cache-components)).

## Refreshing [#refreshing]

An expiring cookie session is refreshed only when you pass `refresh: true`,
which you should only do where cookies can be written: the proxy or
middleware in front of page loads and server actions. With `refresh: true`
the session is refreshed when its token expires within `leeway` seconds (60
by default). Everywhere else the token stays valid until its `exp`, and only
an expired session reads as `{ kind: 'anon', reason: 'expired' }`.

When it does refresh:

* concurrent requests carrying the same refresh token share **one** request
  to Supabase Auth, and a finished refresh is reused for ten seconds, so
  parallel page loads and prefetches converge on one session instead of
  tripping refresh-token reuse detection;
* the new session is written in the exact `@supabase/ssr` format, stale
  cookie chunks are expired, and the response gets `no-store` headers so no
  CDN caches one user's session for another;
* `requestCookies` holds the cookies the rest of the request should see, so
  Server Components rendered after the proxy use the new token;
* a rejected refresh token signs the user out (cookies cleared), and the
  rejection is reused for ten seconds as well; a network failure keeps the
  session so the next request can retry, and a token refreshed early (inside
  `leeway`) still resolves the user until it expires, otherwise the caller is
  `anon` with `reason: "refresh_failed"`;
* a refresh that takes longer than `refreshTimeoutMs` (5000 by default)
  counts as a network failure, so a slow Auth server never holds up the proxy.

```ts
const resolution = await resolveAuth(request, { env, refresh: true });
const response = await next(request, resolution.requestCookies);
return resolution.apply(response); // Set-Cookie + Cache-Control
```

The `/next` adapter wires this up for you in `proxy()`.

> **Refresh races across instances**
>
> Single-flight works per server instance. Across instances Supabase Auth's
> refresh-token reuse interval (10 seconds by default) makes concurrent
> refreshes safe. Keep it enabled; `better-supabase doctor` checks it.

## Custom credentials [#custom-credentials]

`AuthResolver` is an extension point. Return an `AuthState` when the request
carries something you understand, `undefined` otherwise:

```ts
const apiKeys: AuthResolver = {
  name: "partner-api-keys",
  async resolve(request) {
    const key = request.headers.get("x-api-key");
    if (!key) return undefined;
    const partner = await lookupPartner(key);
    return partner ? { kind: "service", keyName: partner.id } : undefined;
  },
};

await resolveAuth(request, { env, resolvers: [apiKeys] });
```

Resolvers may leave `reason` out of an `invalid` state; it counts as
`'token'`. Users they return go through the claims schema like any other.

## Typed claims [#typed-claims]

Claims your [custom access token
hook](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook)
adds are typed `unknown` until you describe them. `betterSupabase.claims(schema)` takes
any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype) and
returns a new definition whose servers validate the verified claims and
whose sessions are typed by the schema's output:

```ts title="src/lib/supabase/index.ts"
import * as v from "valibot";

export const Claims = v.looseObject({
  tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
  roles: v.optional(v.array(v.string()), []),
  memberships: v.optional(
    v.array(
      v.looseObject({
        scope: v.string(),
        id: v.string(),
        roles: v.array(v.string()),
      }),
    ),
    [],
  ),
});

export const betterSupabase = defineSupabase(schema)
  .claims(Claims)
  .use(tenant<v.InferOutput<typeof Claims>>());
```

The tenant plugin, `current_tenant_id()` and the storage and realtime policies
read the active tenant from `tenant_id` (or `app_metadata.tenant_id`).
`claims.tenant` in the config renames it for all of them at once. Memberships
have the shape `{ scope, id, roles }`, and roles and memberships are never
read from `user_metadata`, which users can edit. Use `v.looseObject` so claims
your schema doesn't list, such as fields another hook adds, stay on the
session. `claims(schema)` takes any Standard Schema, so an authorization
library can publish the schema for the claims its hook writes, and you pass
that one instead.

* `createServer`, `createNext`, `createHono` and friends validate after the
  signature check, on every request, including tokens served from the
  verification memo. A token whose claims fail resolves to
  `{ kind: 'invalid', reason: 'claims' }` with an `unauthorized` error
  (`code: 'CLAIMS_INVALID'`) that names the failing paths, never the values.
  The cookie path does not try a refresh for it, since a new token from the
  same hook would fail the same way.
* The output is merged over the payload, so `sub`, `exp` and the other JWT
  claims stay, and defaults such as `roles: []` fill in. The merge is one
  level deep: a nested object in the schema, like `app_metadata`, replaces
  the payload's, so describe it with `v.looseObject()` (or your library's
  equivalent) to keep its other keys.
* `ctx.auth.claims`, `await bs.session()` and the `useSession` from
  `createHooks<typeof bs>()` are typed `JWTClaims & Claims`. The
  standalone `useSession<Claims>()` takes the type explicitly.
* `tenant<Claims>({ claim })` only accepts dotted paths to string claims,
  such as `'tenant_id'` or `'app_metadata.tenant_id'`. Without `claim` it
  reads `claims.tenant` from the generated schema.

Keep the schema small: it runs on every request, and anything the hook
doesn't always set should be optional or have a default.
`better-supabase doctor` checks the hook itself: its grants
([BS404](/docs/cli/doctor#bs404)), that it is `stable` with an empty
`search_path`, and with `--as <user id>` how large the claims it returns are
([BS405](/docs/cli/doctor#bs405)).

### When claims change [#when-claims-change]

Claims are copied into the access token when Auth issues it, and verified
locally after that. When you change a user's `app_metadata`, their
memberships, or anything the hook reads, the user's current token still
carries the old values until it is refreshed: up to the token's lifetime (an
hour by default), or until the proxy refreshes the session. The same goes for
`auth.jwt()` in policies. Revoking a role therefore takes effect on the next
refresh, not immediately.

When that window matters, read the source of truth instead of the claim:
policies can check the `memberships` table (`member_organization_ids()` from the
[SQL modules](/docs/blocks/sql) does). After a change the user made themselves,
such as joining an organization, the browser can call
`supabase.auth.refreshSession()` to pick up the new claims at once. Shorter
token lifetimes narrow the window for everyone
([BS402](/docs/cli/doctor#bs402)).

## Profile [#profile]

`user_metadata` holds what users say about themselves: a display name, an
avatar, a theme. `betterSupabase.userMetadata(schema)` parses it into a typed
`session.profile` for rendering:

```ts title="src/lib/supabase/index.ts"
export const Profile = v.object({
  display_name: v.optional(v.string()),
  avatar_url: v.optional(v.pipe(v.string(), v.url())),
});

export const betterSupabase = defineSupabase(schema)
  .claims(Claims)
  .userMetadata(Profile);
```

```tsx title="src/app/account/page.tsx"
const session = await bs.session();
if (session.kind === "user") {
  return <h1>Hello, {session.profile?.display_name ?? session.user.email}</h1>;
}
```

Any signed-in user can change their own metadata with `auth.updateUser()`, so
the profile is for display only. Roles, memberships, the tenant and every
policy come from the verified claims, never from `profile` or
`user_metadata`.

* The schema runs after the claims schema, on the `user_metadata` claim of the
  verified token. Metadata that fails it leaves `profile` undefined and logs
  one warning per schema through the `logger` of `defineSupabase` with the
  failing paths, never the values. The request still resolves as the user.
* `session.profile` is typed `Profile | undefined` in `ctx.auth`,
  `bs.session()` and the `useSession` from `createHooks<typeof bs>()`.
* Supabase Auth copies `user_metadata` into the access token. A custom access
  token hook must keep that claim for `profile` to be
  set. The claims budget ([BS405](/docs/cli/doctor#bs405)) counts it, so
  keep large or frequently changing fields in a `profiles` table instead.

## Identity for repositories [#identity-for-repositories]

`authContext(auth)` turns an auth state into the repository context: a typed
`actor` (`user`, `service` or `anon`) and the JWT `claims`. The
[`actor`](/docs/plugins/actor) and [`tenant`](/docs/plugins/tenant) plugins
read it.