> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Federated with Supabase

> Supabase Auth owns the account. Sign the Supabase user id on your server and UserKit trusts the claim.

Supabase Auth is your source of truth. UserKit needs to know *which* of your
users is on the page, and it will not take the page's word for it — your server
signs the claim.

<Warning>
  **The identity secret never enters the bundle.** Never prefix it with
  `NEXT_PUBLIC_`, never render it into HTML, never send it to the browser to save
  a round trip. Whoever holds it can mint a **verified** session for any of your
  users.
</Warning>

The contract is the one on the
[federated identity](/en/customer-auth/federated) page and nothing here changes it:

```
hash = hex( HMAC-SHA256( identity_secret, external_id ) )
```

## What Supabase supplies

One value: `user.id`, the uuid on `auth.users`. That is the `external_id`.

```ts lib/supabase.ts theme={null}
import "server-only";

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function currentExternalId(): Promise<string | null> {
  const store = await cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => store.getAll(),
        setAll: () => {},
      },
    },
  );

  const { data, error } = await supabase.auth.getUser();
  if (error || !data.user) return null;
  return data.user.id; // ← the external_id
}
```

<Note>
  `getUser()` and not `getSession()`. `getSession()` decodes the cookie the browser
  sent, so signing its contents would mean signing a value the browser chose.
  `getUser()` asks Supabase.
</Note>

## The server half

```ts app/api/userkit-boot/route.ts theme={null}
import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";

import { currentExternalId } from "@/lib/supabase";

export const dynamic = "force-dynamic";

export async function GET() {
  const externalId = await currentExternalId();
  if (!externalId) {
    return NextResponse.json({ error: "not_signed_in" }, { status: 401, headers: NO_STORE });
  }

  const hash = createHmac("sha256", process.env.USERKIT_IDENTITY_SECRET!)
    .update(externalId)
    .digest("hex");

  return NextResponse.json({ external_id: externalId, hash }, { headers: NO_STORE });
}

// This body identifies one person. A CDN that kept it would hand the next
// visitor a proof of somebody else's identity.
const NO_STORE = { "Cache-Control": "no-store" };
```

Signed out answers `401` rather than an empty pair: "no user" and "a user whose
hash I could not compute" are different situations, and the caller's next move
differs.

## The client half

```tsx theme={null}
"use client";

import { createClient } from "@userkit/js";

const userkit = createClient({
  publishableKey: process.env.NEXT_PUBLIC_USERKIT_PUBLISHABLE_KEY!,
});

const response = await fetch("/api/userkit-boot", { cache: "no-store" });

if (response.status === 401) {
  // Still a visitor. An anonymous boot is where first-touch attribution lands.
  await userkit.boot();
} else {
  const { external_id, hash } = await response.json();
  await userkit.boot({ externalId: external_id, hash });
}
```

`getState().verified` is `true` only when the HMAC checked out. Signed in to
Supabase with `verified: false` means either the secret belongs to the other
environment or the message signed was not exactly the `external_id`.

## Configuration

| Variable                                                    | Where it lives                                                                      |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `USERKIT_IDENTITY_SECRET`                                   | **Server only.** `GET /v1/organization/environments/{id}/identity`, per environment |
| `NEXT_PUBLIC_USERKIT_PUBLISHABLE_KEY`                       | The browser. It identifies the environment and authenticates nothing                |
| `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase's own public pair                                                          |

## What does not travel

A Supabase user carries an `email` and an `email_confirmed_at`. Neither becomes
an identity in UserKit: an email sent through `/v1/boot` is stored as an
attribute, does not resolve to an existing contact and does not become an
identity edge. The HMAC proves the `external_id` and only the `external_id`.

To prove an address, send a [magic link or an email
code](/en/customer-auth/email-proof) — the two flows that arrive in it.

<Card title="The full example" icon="github" href="https://github.com/userkithq/monorepo/tree/main/examples/federated-supabase">
  A runnable Next app with these files, an `.env.example` and the pieces this page
  leaves out.
</Card>
