> ## 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 Better Auth

> Better Auth owns the account, in your own database. Sign session.user.id on your server and UserKit trusts the claim.

Better Auth runs inside your app and against your own database. 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 Better Auth supplies

One value: `session.user.id`. That is the `external_id`.

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

import { headers } from "next/headers";

import { auth } from "./auth";

export async function currentExternalId(): Promise<string | null> {
  const session = await auth.api.getSession({ headers: await headers() });
  return session?.user.id ?? null; // ← the external_id
}
```

<Note>
  `getSession` validates against the database rather than decoding the cookie.
  Here the temptation is at its strongest, because the session table is right
  there — but an id the browser could choose is not something to sign.
</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/better-auth";

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" };
```

## 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
Better Auth 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                |
| `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` | Better Auth's own configuration, all server-side                                    |

## What does not travel

Better Auth's user carries `email` and `emailVerified`. They are statements
about your database, and they do not become identities 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-better-auth">
  A runnable Next app with these files, an `.env.example` and the pieces this page
  leaves out.
</Card>
