# Widget tokens

> Mint short-lived widget JWTs with POST /v1/tokens, choose between the connect and app scopes, and keep a long browser session alive without exposing an API key.

Source: https://docs.customdomain.ai/docs/authentication/widget-tokens

The widget must never see an API key or an application `client_secret`. Your
backend mints a short-lived JWT and hands only that to the browser. There are
two ways to mint it, and which one applies depends on how the application was
created:

| How the application was created                                                                | You have                                                                                    | Mint with                                                                                                                       |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **You signed up in the console** (most customers; every console signup gets an application)    | An API key from **API & SDK**. The console never shows you the application's client secret. | [`POST https://app.customdomain.ai/api/widget-token`](#with-an-api-key-console-accounts) with `Authorization: Bearer <API key>` |
| **The application was created over the API** (`POST /v1/applications`, or `tenants:provision`) | The `client_secret` in that creation response                                               | [`POST /v1/tokens`](#with-the-client-secret-v1tokens) with the secret in the body                                               |

The **Install** tab on the console's **Widget** page generates code for both. Its
**Authenticate with** switch defaults to **API key**.

An API key and a client secret are different credentials. Do not swap them:

|                  | API key                                                                                                                                 | Application client secret                                                                           |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Looks like       | `cd_live_` or `cd_test_` plus 32 hex characters (older keys: `sk_live_` / `sk_test_`)                                                   | `sk_` plus 32 hex characters                                                                        |
| Where you get it | **API & SDK**: the workspace's default key at the top (owners can view it any time), or **Create key** (admins and owners; shown once). | The response of `POST /v1/applications` (shown once). A console-created application never shows it. |
| Sent as          | `Authorization: Bearer …`                                                                                                               | `client_secret` in the body of `POST /v1/tokens`                                                    |
| What it does     | Calls the API for your whole workspace                                                                                                  | Mints widget tokens for one application, nothing else                                               |

Never put an API key in the `client_secret` field: `POST /v1/tokens` always
answers `401` with `unknown application or secret`. See
[Troubleshooting](/docs/troubleshooting#401-unknown-application-or-secret-when-minting-a-widget-token).

## With an API key (console accounts)

Create a key on the console's **API & SDK** page with **Create key**, keep it in
an environment variable on your server, and call the console's token endpoint:

```bash
curl -X POST https://app.customdomain.ai/api/widget-token \
  -H "Authorization: Bearer $CUSTOMDOMAIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "application_id": "app_...", "domain": "app.customer.com" }'
# → 200 { "application_id": "app_...", "token": "eyJ…", "expires_in": 900, "token_type": "Bearer" }
```

* `application_id` is optional and defaults to your account's first application.
  `domain` is optional; pass it to bind the token to one hostname.
* The API key stays on your server. Only the 15 minute token reaches the browser.
* This endpoint mints the default `connect` scope only. It takes no `scope` or
  `end_user_ref`. For an `app` scope token, use `POST /v1/tokens` with a client
  secret.
* Creating a key needs a card on file (the workspace's 14 day free trial; nothing
  is charged until it ends). Without one, **Create key** and
  `POST /v1/applications/{id}/keys` answer `402 billing_required`: an owner or
  admin starts the trial under **Billing**. The workspace's default key needs no
  card, and neither do your first ten domains.

| Status | `error`                 | Meaning                                                                                                                                                        |
| ------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | `unauthorized`          | The `Authorization: Bearer` key is missing, revoked or not accepted.                                                                                           |
| `403`  | `no_application`        | The key's workspace has no applications.                                                                                                                       |
| `403`  | `forbidden`             | `application_id` belongs to another workspace.                                                                                                                 |
| `503`  | `no_widget_credentials` | The application was not created in the console, so the console has no secret for it. Mint with `POST /v1/tokens` and that application's client secret instead. |
| `500`  | `unseal_failed`         | The console could not open its stored copy of the secret. Try again, then contact support.                                                                     |
| `502`  | `mint_failed`           | The token service did not return a token. Try again shortly.                                                                                                   |

The console keeps each console-created application's client secret sealed and
uses it for this endpoint, the widget preview and **Try it live**. **Do not
rotate that secret** (`POST /v1/applications/{id}/client-secret:rotate`): the
console's copy is not updated, so all three stop working for that application.

## With the client secret (`/v1/tokens`)

For an application you created over the API, exchange its `client_secret` for a
token:

```bash
curl -X POST https://api.customdomain.ai/v1/tokens \
  -H "Content-Type: application/json" \
  -d '{
    "application_id": "<APP_ID>",
    "client_secret": "<CLIENT_SECRET>",
    "domain": "app.customer.com"
  }'
# → 201 { "token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "connect" }
```

* `POST /v1/tokens` is **not** bearer-authenticated. It authenticates by the
  `client_secret` in the body, which is why it must run server-side only. A
  wrong or unknown secret is `401`.
* `domain` binds the token to one hostname: a domain-bound token may only act on
  that host. `domains` (a list) allows several hosts in one session. Two things
  need the binding: the [secure sign-in](/docs/guides/secure-sign-in)
  (`POST /v1/connections/{id}/browser:start`) and removing the domain take a
  token bound to the domain and, when a customer connected it, to that
  customer's `end_user_ref`. The secure sign-in refuses a token bound to no
  domain, or only to other domains, with `403 token_not_bound` (its domain check
  says the same in `rails.cloud_browser.reason`, so the widget never offers a
  sign-in it cannot start), and a token for a domain another customer connected
  with `403 token_wrong_end_user`. Removing the domain, and reading its
  monitoring, services or timeline, answer `403 forbidden` to the same tokens,
  which the widget reports as `SessionError`.
* `end_user_ref` (optional) is your own id for the end user the token is for. It
  is stored on the connection, shown in the console, sent with every webhook as
  `user_id`, and owns any API key the secure sign-in makes for that user. Ids
  that start with `console:` are reserved: the console's own **Try it live**
  mints its tokens as `console:<member id>`, and the console never counts such a
  connection as a customer. Do not start your ids with it.
* `end_user_email` (optional) is the end user's address for
  [end user notices](/docs/connect-flow/end-user-notices). It is never put in
  the token; a connection created with the token takes it, unless the connection
  already has an address (the stored one stays) or belongs to another end user
  than this token's `end_user_ref` (it stores none).
* A token acts only on its own customer's connections. A connection that names a
  customer is the token's when that is the token's `end_user_ref`. Another
  customer's token, or a token with no `end_user_ref` when the connection names
  one, answers `403 token_wrong_end_user` on creating a connection for a domain
  that customer connected, on every other route that takes the connection's id
  (reading it, its records, certificate, diagnosis, SSL and Power; applying
  records; starting a sign in, Domain Connect or the Route 53 role; rechecking,
  re-applying, turning off its managed mode with `DELETE /v1/connections/{id}/grant`),
  and on every managed DNS and nameserver move route. Removing the domain, and
  reading its monitoring, services or timeline, keep their own rule and answer
  `403 forbidden`. A connection that names no customer (you made it with your API
  key) is any token's on those routes. For managed DNS and nameserver moves it is a
  token's only when its domain is one the token is bound to (`domain` or `domains`);
  a token with no `end_user_ref` and no `domain` counts only the domain of the
  connection the call is about, and a token with an `end_user_ref` and no `domain`
  owns none of those. &#x2A;*If you create connections with an `end_user_ref`, mint each
  widget token with the same `end_user_ref` and the `domain` it is for.** A token with
  no `end_user_ref` now gets `403` on a connection that names one, including a
  connection that token created by sending `end_user_ref` in the request body.
  Share links keep working, under one rule (see
  [Email these instructions to someone else](/docs/widget-sdk/reference)): &#x2A;*a share
  link's token owns exactly what the token that made it owned, and may touch only the
  shared domains.** It acts as the end user who shared it, for the connections that
  end user owned: a link made by a token that names no customer and is bound to no
  `domain` counts only the one connection a call is about as its own, so a zone that
  links several of the shared domains is not its own, and a link made by a token that
  names a customer and is bound to no `domain` owns none of your connections that name
  no customer. It reaches only the shared domains, so a customer who is bound to no
  `domain` and shares one of their domains gives a link for that domain and none of
  their others. A widget token can share only a domain whose connection it owns by the
  rules above (otherwise `403 token_wrong_end_user` and no link; a domain with no
  connection yet can be shared). A share link sets its domain up as its sharer could
  (the secure sign-in, managed DNS, a nameserver move, the monitoring, services and
  timeline reads). It cannot remove the domain (`DELETE`, `:disconnect` and the
  sign-in to remove answer `403 forbidden`, whether or not the connection names a
  customer), cannot set the customer's contact address on any connection, cannot renew
  the customer's key (a sign-in in reconnect mode answers `403 forbidden`), is never
  offered the customer's saved keys, and a key made at a sign-in it starts is not the
  customer's own: it never replaces a key the customer already keeps, and it is never
  offered to the customer on another domain. A reconnect link is the customer's own and
  its token stays the customer, with two changes: one whose key was replaced by another,
  or whose connection is gone, now answers `404` like an expired link (it used to open
  the plain flow), and a sign-in in reconnect mode that is not the customer's own
  session on a key the customer's own sign-in made (a reconnect link on a connection
  that names no customer, an API key without the console's signed forward, the console)
  is refused with `403 forbidden` before it starts.
* Only the creation response shows a client secret. If you lost it, rotate it
  (`POST /v1/applications/{id}/client-secret:rotate`, needs `keys:manage`),
  except for a console-created application (see above).

## Lifetime

* The default lifetime is 15 minutes (`expires_in` is in seconds). Adding DNS records by hand
  often takes longer than that, so give the widget a way to get a fresh token.
  See [Keeping a session alive](#keeping-a-session-alive).

## Scopes

The optional `scope` field decides what the token may do. Leave it out for the
token you hand to an end user.

| Scope               | Who holds it                                             | What it can do                                                                                                                                                                                                       |
| ------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect` (default) | Your end user's browser                                  | Run the connect flow for a domain: check it, create the connection, read its records and status, diagnose its DNS, apply records, and start sign-in or one-click setup.                                              |
| `app`               | Your server, or an admin screen only your team can reach | Everything `connect` can, plus acting as the whole application: listing every connection, webhooks, SSL certificates, Power, billing usage, the audit log, registrar orders and direct purchases, and bulk creation. |

A `connect` token that calls an application-wide endpoint gets `403`. Mint an
`app` token (a `POST /v1/tokens` body, so it needs the client secret), or use an
API key, for those calls:

```json
{ "application_id": "<APP_ID>", "client_secret": "<CLIENT_SECRET>", "scope": "app" }
```

Never hand an `app` token to an end user. It can read every other customer's
domains in the application.

## What a widget token can do

A widget JWT is accepted only on the browser-facing Connect endpoints:
`POST /v1/domains:check`, `POST /v1/connections`, `GET /v1/connections/{id}`,
`GET /v1/connections/{id}/records`, `GET /v1/connections/{id}/diagnose`,
`POST /v1/connections/{id}:recheck`, the three apply rails (`/apply`,
`/oauth:start`, `/domainconnect:start`), `POST /v1/monitor:check`, and the
registrar search, quote and checkout endpoints. With scope `app` it also reaches
the application-wide endpoints listed above. No widget token can create
applications, manage keys, or manage members: those need an API key or the
management key.

## Tokens for a test mode application

A token minted with the `client_secret` of a [test mode](/docs/guides/test-mode)
application carries a signed `sandbox: true` claim, which is how the widget knows to
say so and to ask for a `.test` name. Nothing else about minting changes. The claim is
never what grants or refuses anything: the control plane decides from the application
on every call, and a test mode token can run the connect flow for its own `.test`
names and read its own webhooks, and nothing else (`403 sandbox_not_allowed`). A token
of a live application has no such claim.

## Keeping a session alive

Pass `getToken` to `open()`. When the widget's token expires mid-setup it calls
`getToken`, swaps in the new token and carries on, so the person adding records
never sees an error:

```js
async function mintToken() {
  // Your own endpoint, which mints the token server-side (see above).
  const res = await fetch("/api/customdomain-token", { method: "POST" });
  return (await res.json()).token;
}

customdomain.open({
  applicationId: "app_123",
  token: await mintToken(),
  getToken: mintToken,
});
```

Without `getToken`, an expired session tells the user they can close the
window. Nothing is lost: the server keeps checking DNS and the connection goes
live on its own once the records appear.

See [The widget SDK](/docs/widget-sdk/overview) for how the browser uses the
token, and [Create a connection](/docs/connect-flow/create-a-connection) for the
calls it drives.
