---
title: "Sign in your own users"
description: "Identified users for a surface built on @guuey/chat or the client SDK — one token endpoint on your backend, no OIDC provider, same identity as the widget."
---

A surface you build yourself — on the [chat kit](/chat/) or the [client SDK](/sdk/) — can know **who the visitor is**, so their conversations, memory, files and profile follow them across sessions. There are two ways to tell it, and you pick by what you already run:

| You have…                                                | Use                                                                                                                                                                                                                                                                                                                                                                                                                 |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| An OIDC-compatible identity provider (Cognito, Auth0, …) | [Bring your own auth](/sdk/#signed-in-users-bring-your-own-auth): point the app at your issuer and forward your users' ID tokens. Guuey verifies them against your provider's published keys.                                                                                                                                                                                                                       |
| Just a backend that knows who is logged in               | **This page.** Enrol the app in Guuey's per-app token issuer once, mint short-lived tokens for your signed-in users from your backend with [`@guuey/widget-auth`](https://www.npmjs.com/package/@guuey/widget-auth), and hand them to the SDK. No IdP, no JWKS to host, no signing key on your side — it is the same endpoint an [identified widget embed](/embed/#who-the-visitor-is) uses, and the same identity. |

## How it works

1. You enrol the app once. Guuey generates a signing keypair for it (the private half is sealed in Guuey's KMS and never leaves it) and prints an **app secret** for your backend.
2. Your backend adds a **token endpoint**: it authenticates the visitor's session the way it always does, then calls Guuey's mint route with the app secret and the visitor's stable user id, and returns the short-lived token it gets back. The browser never sees the secret.
3. Your surface passes that token to the SDK's `getAccessToken`. Every turn and every history read carries it, and the agent resolves the visitor to a durable identity derived from the `userId` you sent.

The token is a plain signed JWT bound to your app's own issuer and audience. Nothing in it says _widget_ or _custom surface_ — every Guuey surface verifies it the same way and derives the same identity — so **a user is the same person on your page, in the widget, and on the app's standalone page**, as long as your backend sends the same `userId`. What exactly a token proves, how issuer and audience bind it to one app, and where guests fit is on [The identity model](/concepts-identity/).

## 1. Enrol the app

```bash
guuey widget keys create <appId> --audience <your-audience>
```

The secret is printed **once** — store it the way you store a database password. `--audience` is any string you choose (a name for your product is fine); it is baked into every token and checked on every verification. With `--audience`, the command also configures the app to trust its own issuer in the same step (`userAuthMode: byo`, pointing at Guuey's per-app issuer).

:::note[If the app already has users]
A freshly created app has no auth mode set, so the one-step path just works. The command refuses to flip an app whose mode was explicitly set to `anonymous` or `native_pool` — switching auth modes gives every existing end-user a new identity, so it will not do that as a side effect. If that is what you want, do it explicitly with the command it prints (`guuey apps update <appId> --auth-mode byo --issuer-url <issuer> --audience <aud>`).
:::

## 2. Add a token endpoint to your backend

Any framework works; the shape is one authenticated route that returns a token as plain text. This is a Next.js route handler:

```ts
// app/api/guuey-token/route.ts
import { signUserToken } from "@guuey/widget-auth";
import { getSession } from "@/lib/auth";

export async function GET(request: Request) {
  const session = await getSession();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const { token } = await signUserToken(
    { userId: session.userId, name: session.name, email: session.email },
    {
      appId: process.env.GUUEY_APP_ID!,
      appSecret: process.env.GUUEY_APP_SECRET!,
      // The API origin, without `/v1` — or set GUUEY_API_URL.
      apiBaseUrl: "https://api.us-east-1.guuey.com",
    }
  );
  return new Response(token, { headers: { "content-type": "text/plain" } });
}
```

`userId` becomes the token's subject and is what the visitor's identity is derived from — it must be **stable for the life of the account**. A session id or an editable email silently orphans everything the user has when it changes.

The secret is server-side only: `signUserToken` refuses to run in a browser, because a secret in a shipped bundle lets every visitor mint a token for any of your users. Tokens last 15 minutes by default (`ttlSeconds` up to 3600); if your endpoint caches them, cache **per user** and expire a couple of minutes early — see the package README.

## 3. Hand the token to the SDK

Pass a `getAccessToken` resolver that fetches from your endpoint. With the drop-in kit:

```tsx
import { GuueyChat } from "@guuey/chat/react";

async function getAccessToken({ forceRefresh = false } = {}) {
  const res = await fetch(`/api/guuey-token?reason=${forceRefresh ? "expired" : "initial"}`);
  if (!res.ok) throw new Error(`token endpoint failed: ${res.status}`);
  return res.text();
}

<GuueyChat
  endpointUrl="https://your-agent-endpoint"
  appId="your-app-id"
  apiBaseUrl="https://api.us-east-1.guuey.com/v1"
  getAccessToken={getAccessToken}
/>;
```

With the raw client, the same resolver goes to `createWebAdapters({ apiBaseUrl, getAccessToken })`.

Two rules that keep this correct:

- **Throw on failure — never return `null`.** To the SDK, `null` means "signed out on purpose". A signed-in surface whose token fetch quietly returns `null` would continue as an anonymous visitor, and those turns land in a guest thread the user can never reach. Failing loudly is the right behavior.
- **`forceRefresh` means "that token was just rejected" — mint fresh.** The SDK asks the resolver anew on every attempt it sends, and calls it with `{ forceRefresh: true }` when a history read is refused on a token the resolver already returned — the same signal as the widget's `getToken("expired")`. If your endpoint caches, forward the reason and skip the cache for `expired`, and expire cached tokens a couple of minutes before they do.

Supply **one** identity mode per surface: `getAccessToken` for signed-in users, `getGuestSecret` for anonymous ones — never both.

## 4. Allow your surface's origin

Your agent accepts browser requests from `localhost` and Guuey's own surfaces automatically. Before your surface goes to production, allow the domain it runs on — this admits both the live stream from the agent and the transcript read on reload:

```bash
guuey apps update <appId> --domains "yourapp.com"
guuey apps check --origin https://yourapp.com   # sends a real preflight, prints the verdict
```

This is the agent endpoint's CORS check: a bare domain covers `https://yourapp.com` and every subdomain, and `localhost` needs no entry. The **widget's** frame allowlist reads the same list more strictly — a bare domain matches its apex only, and a local origin must be listed with its scheme — so if you also embed the widget, list explicit origins; see [Allow your site's domain](/embed/#1-allow-your-sites-domain).

## What you get

- **Continuity across surfaces.** The same `userId` from your backend resolves to the same Guuey identity on your page, in the [widget](/embed/), and on the app's [standalone page](/domains/) — one history, one memory, one profile.
- **History on reload.** With `apiBaseUrl` set, the SDK repaints the visitor's earlier turns from the read plane using the same token.
- **Fail-loud, never fail-anonymous.** A rejected token surfaces as an error on the turn; it never silently degrades to a guest identity.

## Already embedding the widget?

You are done: the widget's identified mode uses this exact endpoint. Point your custom surface's `getAccessToken` at it and both surfaces share the user.

One exception: the app's **standalone page** cannot use this route as its [identity endpoint](/identity-endpoint/) — that page requires a JSON `{ "token": … }` body plus an exact CORS contract, not the raw string this route returns. Reuse the minting code, but give the standalone page its own route.

## Troubleshooting

- **`signUserToken` throws `WidgetAuthAppNotConfiguredError` (409).** The app is not configured to trust its own issuer — re-run `guuey widget keys create` with `--audience`, or set the binding with `guuey apps update` as printed.
- **`signUserToken` throws `WidgetAuthCredentialError` (401).** Wrong, revoked, or another app's secret — deliberately indistinguishable. Check the value, then `guuey widget keys rotate <appId> --new-secret` if in doubt.
- **Turns fail with an unexplained `Failed to fetch`.** That is a CORS refusal, not an identity problem — the origin is missing from `--domains`. `guuey apps check --origin` tells you.
- **Turns fail with `401`.** The token was rejected — expired past its TTL with a caching endpoint that ignored `expired`, or minted before the app's binding was fixed. Mint fresh.