> ## 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 identity

> You already have auth. Sign the user's id on your server and UserKit trusts the claim.

In federated mode your product owns the account. 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.

The proof is an HMAC:

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

<Warning>
  The `identity_secret` never reaches the browser. Compute the hash on your server,
  render it into the page, and hand it to boot. A secret in a bundle is a secret in
  every visitor's hands — and this one mints *verified* sessions.
</Warning>

## Setup

<Steps>
  <Step title="Switch the environment to federated">
    ```bash theme={null}
    curl -s -X PATCH $API/v1/organization/environments/{id} \
      -H "Authorization: Bearer $UK_SESSION" \
      -H "X-Organization-Id: org_4b1e…" \
      -H 'Content-Type: application/json' \
      -d '{ "auth_mode": "federated" }'
    ```

    Requires `organization:update`.
  </Step>

  <Step title="Read the identity secret">
    ```bash theme={null}
    curl -s $API/v1/organization/environments/{id}/identity \
      -H "Authorization: Bearer $UK_SESSION" \
      -H "X-Organization-Id: org_4b1e…"
    ```

    ```json theme={null}
    {
      "environment_id": "…",
      "auth_mode": "federated",
      "identity_secret": "uk_is_7c2a…",
      "rotated_at": null
    }
    ```

    Requires `api_keys:write` — this secret mints verified sessions, so reading it is
    holding it, and a read gate would be the wrong gate. It is minted on first read:
    until you have read it, no HMAC it could verify exists.
  </Step>

  <Step title="Put it in your server's environment">
    ```bash theme={null}
    USERKIT_IDENTITY_SECRET=uk_is_7c2a…
    ```

    Per environment. Your test deployment holds the test environment's secret.
  </Step>
</Steps>

## Sign the claim

<CodeGroup>
  ```ts Node theme={null}
  import { createHmac } from "node:crypto";

  export function userkitHash(externalId: string) {
    return createHmac("sha256", process.env.USERKIT_IDENTITY_SECRET!)
      .update(externalId)
      .digest("hex");
  }
  ```

  ```go Go theme={null}
  func userkitHash(externalID string) string {
  	mac := hmac.New(sha256.New, []byte(os.Getenv("USERKIT_IDENTITY_SECRET")))
  	mac.Write([]byte(externalID))
  	return hex.EncodeToString(mac.Sum(nil))
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, os

  def userkit_hash(external_id: str) -> str:
      return hmac.new(
          os.environ["USERKIT_IDENTITY_SECRET"].encode(),
          external_id.encode(),
          hashlib.sha256,
      ).hexdigest()
  ```

  ```ruby Ruby theme={null}
  require "openssl"

  def userkit_hash(external_id)
    OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("USERKIT_IDENTITY_SECRET"), external_id)
  end
  ```
</CodeGroup>

The message is the `external_id` and nothing else. No timestamp, no email, no
concatenation.

## Boot

```bash theme={null}
curl -s $API/v1/boot \
  -H 'Origin: https://app.example.com' \
  -H 'Content-Type: application/json' \
  -d '{
    "publishable_key": "uk_pk_live_…",
    "external_id": "user_8421",
    "hash": "9a3f…",
    "email": "grace@example.com",
    "anonymous_id": "anon_2f9c1b"
  }'
```

```json 200 (or 201 when the contact was created) theme={null}
{
  "contact": { "id": "3f9a…", "identified": true },
  "token": "uk_ct_…",
  "expires_at": "2026-08-28T12:00:00Z",
  "environment": "live",
  "verified": true
}
```

### The three hash outcomes

| `hash`            | Result                                                                                                      |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| Valid             | Session is **verified**                                                                                     |
| Absent            | Session is **unverified** — usable in development, marked in the panel, and held back from the routes below |
| Present but wrong | `401 invalid_identity_hash`                                                                                 |

A wrong hash is refused outright and never downgraded to unverified. Silently
accepting a bad signature would make a typo in your HMAC code invisible — you would
ship it and find out much later.

### What an unproven session cannot do

A hashless boot asserts an `external_id` and proves nothing, and it is a call
anybody who knows somebody's `external_id` can make from a browser. So the
session it mints stops at the routes where that assertion would start to
matter, each with `403 unverified_session`:

| Route                               | Why                                                                                                                                     |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/contact/token`            | The JWT is what your own backend treats as proof of identity — it would turn an unproven claim into an authenticated request over there |
| `GET`/`DELETE /v1/contact/sessions` | Lists and revokes the devices and addresses a person signed in from                                                                     |
| `POST /v1/contact/two-factor/*`     | Administers somebody's second factor, disabling included                                                                                |

`GET /v1/contact/me` is not on the list: `boot` already answered with the
contact it resolved, so gating it would close nothing.

**Anonymous sessions are unaffected.** A visitor session is unverified too, but
there is nobody to impersonate — the row was created by the same `boot` call
now holding it, so its data is the caller's own by construction. What the rule
is about is the one combination where the account on the other end belongs to
somebody else: unverified *and* identified.

The way past it is the way you would want in production anyway — configure the
identity secret and send the hash.

### An unproven boot does not rewrite a profile

A hashless boot may **create** a contact, and it is always a sighting. What it
may not do is change the `name` or `email` of a contact that already exists.

Those two are where security notices are addressed and where the JWT's `email`
claim comes from. If an unproven assertion could overwrite them, whoever made
it would receive the victim's own warnings. A contact the same call just
created has no previous owner to take anything from, so it keeps what it
arrived with.

### The email is an attribute, always

This is the rule that keeps federated mode safe, and it is structural rather than
a check.

An email arriving through `/v1/boot` is stored on the contact row. It does **not**
resolve to an existing contact, and it does **not** become an identity edge.

The HMAC proves the `external_id` and only the `external_id`. If an email in the
same request could resolve to a contact, then a valid hash for *your own* id plus
*someone else's* email would reach that someone's data. That is the takeover shape,
and it is closed by construction.

To prove an email in federated mode, send a
[magic link](/en/customer-auth/hosted#magic-links) — the one flow that upgrades an
email from attribute to proven identity, and it works in both modes.

## Anonymous boot

Boot without an `external_id` is a visitor sighting, and it works in **both** modes:

```bash theme={null}
curl -s $API/v1/boot \
  -H 'Origin: https://example.com' \
  -H 'Content-Type: application/json' \
  -d '{
    "publishable_key": "uk_pk_live_…",
    "anonymous_id": "anon_2f9c1b",
    "attribution": {
      "utm_source": "newsletter",
      "utm_campaign": "july-launch",
      "referrer": "https://news.ycombinator.com/",
      "landing_page": "https://example.com/pricing"
    }
  }'
```

This is where **first-touch attribution is born** — on the anonymous contact,
before this person has a name. When they later sign in and boot carries both
`anonymous_id` and `external_id`, the visitor is folded into the identified
contact and the attribution comes with it.

Send the UTMs from the page, where they actually are.

## Rotating the secret

```bash theme={null}
curl -s -X POST $API/v1/organization/environments/{id}/identity/rotate \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…"
```

```json theme={null}
{ "environment_id": "…", "identity_secret": "uk_is_new…", "grace_hours": 24 }
```

The previous secret keeps verifying for **24 hours** — long enough for a fleet to
redeploy, short enough that a stolen old secret has a deadline. Deploy the new
value within the window; after it, hashes signed with the old secret are refused.

## Failure modes

| Response                             | Cause                                                                                             |
| ------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `401 invalid_identity_hash`          | Wrong secret, wrong environment's secret, or the message signed was not exactly the `external_id` |
| `409 environment_not_federated`      | The environment is in hosted mode                                                                 |
| `403 origin_not_allowed`             | The page's origin is not on the publishable key's list                                            |
| `429 rate_limited`                   | 240 boots per minute per IP, or per publishable key                                               |
| `501 federated_identity_unavailable` | Federated identity is unavailable on the server                                                   |

<Note>
  `GET /v1/organization/environments/{id}/identity` is re-readable by design: your
  server needs the value in an environment variable, and a secret you can only see
  once is a secret somebody pastes into a chat.
</Note>
