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

# Session tokens

> A short-lived JWT your backend verifies offline, and the opaque session that keeps minting them.

Your pages hold a `uk_ct_…` contact session. Your **backend** needs to know who is
calling it, and asking us on every request would put our latency and our uptime in
front of your API.

So the contact session buys a short-lived **JWT**, signed per environment, that
your backend verifies offline against a public key set.

```
your page ──uk_ct_… ──▶ POST /v1/contact/token ──▶ JWT (5 min)
                                                    │
your page ──Authorization: Bearer <JWT> ──▶ your backend
                                                    │
                                       verifies against GET /v1/jwks/{publishable_key}
                                       (cached — no call to us per request)
```

Two credentials, two jobs. The opaque session is **long-lived and revocable**. The
JWT is **short-lived and unrevocable**, which is exactly why it is short.

## Minting

```bash theme={null}
curl -s -X POST $API/v1/contact/token \
  -H "Authorization: Bearer uk_ct_…"
```

```json theme={null}
{
  "token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjZmMWMifQ…",
  "expires_at": "2026-07-29T12:05:00Z"
}
```

This is the refresh flow, and there is no second credential to manage: **the opaque
session is the refresh token**. Call again whenever the last JWT is near expiry,
for as long as the session lives.

<Note>
  Each mint records the contact against the month's active-contact meter,
  idempotently. That is the number your bill reads — see
  [the meter](#the-active-contact-meter).
</Note>

## What the token carries

<ResponseField name="iss" type="string">
  The environment id. **Pin it.** A test-environment token must not satisfy a live
  check.
</ResponseField>

<ResponseField name="sub" type="uuid">
  The contact id.
</ResponseField>

<ResponseField name="sid" type="uuid">
  The session id — what you pass to `DELETE /v1/contact/sessions/{id}`.
</ResponseField>

<ResponseField name="external_id" type="string">
  Your own id for this person, present when the contact has one. In federated mode
  this is what your backend keys on.
</ResponseField>

<ResponseField name="email" type="string" />

<ResponseField name="verified" type="boolean">
  Whether the identity behind the session was **proven**. Gate on this before
  anything another person's data could leak through — a `verified: false` token is
  a perfectly valid signature over an unproven claim.
</ResponseField>

<ResponseField name="iat / exp" type="integer">
  Unix seconds. The lifetime is 5 minutes.
</ResponseField>

## Verifying

Fetch the key set once, cache it, and verify locally.

```bash theme={null}
curl -s $API/v1/jwks/uk_pk_live_3d8e…
```

```json theme={null}
{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
      "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
      "kid": "6f1c…",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}
```

Addressed by **publishable key** — the one identifier your backend already holds in
config. Public and unauthenticated by design: these are public keys. There is no
Origin gate either, because the caller is a server and servers send no Origin.

<CodeGroup>
  ```ts Node — jose theme={null}
  import { createRemoteJWKSet, jwtVerify } from "jose";

  const jwks = createRemoteJWKSet(
    new URL(`${API}/v1/jwks/${process.env.USERKIT_PUBLISHABLE_KEY}`),
  );

  export async function contactFromRequest(authorization?: string) {
    const token = authorization?.replace(/^Bearer /, "");
    if (!token) return null;

    const { payload } = await jwtVerify(token, jwks, {
      issuer: process.env.USERKIT_ENVIRONMENT_ID, // pin the environment
    });
    return payload;
  }
  ```

  ```go Go — go-jose theme={null}
  set, err := jwk.Fetch(ctx, apiURL+"/v1/jwks/"+publishableKey)
  if err != nil {
  	return nil, err
  }

  claims, err := jwt.Parse(
  	[]byte(token),
  	jwt.WithKeySet(set),
  	jwt.WithIssuer(environmentID), // pin the environment
  	jwt.WithValidate(true),
  )
  ```

  ```python Python — PyJWT theme={null}
  from jwt import PyJWKClient
  import jwt

  jwks = PyJWKClient(f"{API}/v1/jwks/{PUBLISHABLE_KEY}", cache_keys=True)

  def contact_from_token(token: str):
      key = jwks.get_signing_key_from_jwt(token).key
      return jwt.decode(
          token,
          key,
          algorithms=["ES256"],
          issuer=ENVIRONMENT_ID,  # pin the environment
      )
  ```
</CodeGroup>

<Warning>
  Verify the signature. Do not read the payload without it — a JWT is base64, not
  encryption, and anything can be typed into one.
</Warning>

### Cache the key set

The response carries:

```
Cache-Control: public, max-age=300, stale-while-revalidate=86400
```

That is deliberate. A verifier has to keep verifying through our bad five minutes,
so a CDN or an in-process cache should keep answering while a refetch is retried.
Most JWKS clients honour this for you; the ones above do.

Keys are minted on first read, so the document is never empty for a configured
environment. When JWT minting is unavailable on the server, the answer is an
**empty key set** rather than an error — the shape stays valid, and
`POST /v1/contact/token` is where you get the `501 jwt_unavailable`.

## Revocation, and the five minutes

A JWT is verified offline, so nothing can recall one. Deleting a session stops the
**next** refresh, not the token already in a page.

That makes five minutes the longest a revoked session keeps working — and it is
the whole reason the long-lived credential is the opaque, revocable one and the
short-lived credential is the signed one. If you need immediate revocation for a
particular action, check the session server-side for that action rather than
lengthening the JWT.

## Sessions the contact can see

The contact session token lives on your domain without `httpOnly`. That makes "see
my devices and end one" part of the token's safety case, not a nice-to-have.

<CodeGroup>
  ```bash List theme={null}
  curl -s $API/v1/contact/sessions -H "Authorization: Bearer uk_ct_…"
  ```

  ```bash Revoke one theme={null}
  curl -s -X DELETE $API/v1/contact/sessions/{id} -H "Authorization: Bearer uk_ct_…"
  ```

  ```bash Sign out everywhere else theme={null}
  curl -s -X DELETE $API/v1/contact/sessions -H "Authorization: Bearer uk_ct_…"
  ```
</CodeGroup>

```json theme={null}
{
  "sessions": [
    {
      "id": "…",
      "current": true,
      "verified": true,
      "created_at": "…",
      "last_seen_at": "…",
      "expires_at": "…"
    }
  ]
}
```

Revocation is deletion — immediate, with the JWT lifetime as the only tail. "Sign
out everywhere else" keeps the session that asked. Another contact's session id
and one that never existed answer the same `404`.

## Rotating the signing key

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

```json theme={null}
{ "environment_id": "…", "kid": "9b2d…", "grace_hours": 24 }
```

Requires `api_keys:write` — the same gate as the identity secret, because this key
mints the tokens your backend trusts.

The old key stops signing immediately and keeps **verifying** for 24 hours: the
overlap that lets JWKS caches refresh and in-flight tokens expire. Your backend
needs no deploy — it refetches the key set and finds both `kid`s.

For a suspected compromise the arithmetic runs the other way: every token signed
with the old key is dead within its 5-minute lifetime of the rotation. That is the
bound to quote.

## The active-contact meter

```bash theme={null}
curl -s $API/v1/organization/usage/active-contacts \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…"
```

```json theme={null}
{
  "environment_id": "…",
  "month": "2026-07",
  "active_contacts": 1842,
  "limit": 50000,
  "period_ends_at": "2026-08-01T00:00:00Z"
}
```

Requires `customers:read`. A contact counts as active for a month when a JWT is
minted for them — so a session opened in January and refreshed in February is
active in both.

**Live environment only**, because only live contacts count at all. The month is
UTC, decided once in the schema: display may translate it, a bill may not be
ambiguous. This reads the same query the bill reads.
