Tokens
exd bearer credentials: format, types, lifecycle, and the HTTP API for managing them. Authorization decisions — who can call which endpoint — live in access-control.
Credential types
All API requests authenticate with Authorization: Bearer <credential>.
| Credential | Issued by | Intended use |
|---|---|---|
| Human session token | Login flow for an admitted tenant user | Browser UI and interactive CLI use. Effective permissions come from the user's tenant and flag-namespace roles. |
| Service token | Token management API, admin UI, or exd-server-admin CLI | Automation, CI, SDK clients, agents, operational scripts. Permissions come from token type, binding, and optional scopes. |
Namespace-scoped endpoints carry the owning tenant in the URL path (/api/v1/tenants/{tenant}/namespaces/{ns}/...); the server cross-checks the path's {tenant} against the authenticated session or service-token binding and rejects mismatches with 403 Forbidden. The token-management endpoints (/api/v1/tokens) live at the installation level because the token id is itself the resource and the tenant or namespace binding is recorded on the token record.
Service token types
| Type | Prefix | Binding | Permission summary |
|---|---|---|---|
namespace-read | exd_read_ | One (tenant, namespace) | Download that flag namespace's manifest and call evaluation endpoints. Intended for the Rust SDK and read-only automation. |
namespace-write | exd_write_ | One (tenant, namespace) | All namespace-read permissions plus manifest upload, lint, rollback, and version metadata. Cannot create or delete flag namespaces. |
namespace-client | exd_client_ | One (tenant, namespace, environment) | Call /evaluate and /evaluate/all for the bound (tenant, namespace, environment) triple, and only when that environment has public_evaluate = true. Intended for browser bundles and other untrusted clients where the token cannot be kept secret. |
tenant-admin | exd_tenant_ | One tenant | Administrative automation across all flag namespaces in the bound tenant: namespace lifecycle, namespace token issuance, manifest operations, namespace-admin management. |
superadmin | exd_admin_ | Installation | Installation-wide administration across all tenants and flag namespaces. For bootstrap, break-glass, and tightly controlled platform automation. |
Service tokens never become human users and are never members of tenant-admin or namespace-admin lists. They carry explicit token permissions and are bound to their issued scope. The canonical service-token permission matrix is access-control § Service token permissions.
Schema 1.0 defines no optional service-token scopes. The scopes field on token records is reserved for future use and MUST be the empty array.
namespace-client tokens
namespace-client tokens are designed for environments where the token secret cannot be kept secret — typically a JavaScript bundle shipped to a browser. Their security model differs from every other service-token type:
- The secret is treated as public. Anyone who can fetch the JavaScript bundle can read the token. Authorization decisions MUST NOT rely on the token being unknown to attackers.
- The token is bound to one
(tenant, namespace, environment)triple, not just one flag namespace. The bound environment is recorded asenvironment_slugon the token record and is enforced on every evaluation request. - The bound environment MUST have
public_evaluate = trueinnamespace.tomlat the time of every authentication. If the manifest later flips it back tofalse, everynamespace-clienttoken bound to that environment fails immediately with403 Forbidden— without revoking the token records — so manifest authors retain a kill switch independent of token rotation. - Permissions are limited to
evaluate.publicfor the bound(tenant, namespace, environment)triple. The token cannot download the manifest, list versions, lint, or call any admin endpoint. - CORS is gated by an explicit Origin allowlist stored on the token record. The server only adds
Access-Control-Allow-Originheaders for requests whoseOriginmatches an entry inallowed_origins. Native (non-browser) callers are unaffected by CORS but are still subject to all other restrictions.
Additional fields on namespace-client token records
| Field | Type | Description |
|---|---|---|
environment_slug | string | The bound environment slug. Required at issuance for namespace-client; MUST be null for every other token type. |
allowed_origins | array of strings | Origin allowlist for browser-CORS responses. Each entry is an Origin value as defined by RFC 6454 (e.g., https://app.example.com). The wildcard "*" is rejected at issuance. May be empty for non-browser deployments; in that case the server emits no CORS headers. |
namespace-client tokens MUST NOT carry any optional scope. Issuance rejects such requests with 400 invalid_request.
Issuance and rotation
Issued through POST /api/v1/tokens with type = "namespace-client", tenant_slug, namespace_slug, environment_slug, and allowed_origins in the request body. Authorization is identical to other namespace-bound tokens (namespace admin, tenant admin, tenant-admin token, or superadmin). The server additionally verifies that the named environment exists in the flag namespace's current manifest; environments not present in [namespace.environments] are rejected with 400 invalid_request.
Rotation, revocation, and lifecycle states behave identically to other service tokens. Because namespace-client tokens are public-by-design, operators SHOULD prefer short expires_at values (30–90 days) and use rotation rather than long-lived secrets, since revoking a leaked browser-embedded token requires a deploy in addition to the API call.
Telemetry and rate limiting
Successful authentications under a namespace-client token are tagged principal_type = "client" in audit and telemetry events so admins can distinguish browser traffic from server-side traffic. Rate limits for evaluation under namespace-client are scoped per (tenant, namespace, environment, Origin) rather than per token (see server-api § Rate limits) — one bad actor cannot meaningfully consume a flag namespace's quota by minting many Origin values they do not control on browsers they do not own.
Token format
Service tokens follow:
exd_<type>_<payload>
<payload> is 32 bytes of cryptographically random data encoded in Base58. The prefix is public metadata used for routing and operator recognition; it is not sufficient for authorization.
Example tokens:
exd_write_7gHkLmN3pQrStUvWxYz2AbCdEfGhJkLmNpQr3s
exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb
exd_client_8jKmPqRsTuVwXyZaBcDeFgHiJkLmNoPqRsTu
exd_tenant_5cNpQrStUvWxYzAbCdEfGhJkLmNoPqRsTuVwXz
exd_admin_2mNpQrStUvWxYzAbCdEfGhJkLmNoPqRsTuVwXy
The plaintext token is returned only once, immediately after creation or rotation. API responses after that expose only token metadata and a non-secret prefix.
Token records
The server stores token metadata separately from the token secret.
| Field | Type | Description |
|---|---|---|
id | string | Stable opaque identifier, e.g. tok_01HV5Z9R4Y0XJ7A2W8WJ9R7S3A. |
type | string | One of the service-token types. |
name | string | Operator-provided display name, unique within the bound scope. |
description | string | null | Optional operational note. |
tenant_slug | string | null | Bound tenant for tenant and flag-namespace tokens. null only for superadmin tokens. |
namespace_slug | string | null | Bound flag namespace for namespace tokens. |
environment_slug | string | null | Bound environment. Required for namespace-client; null for every other token type. |
allowed_origins | array of strings | Origin allowlist for namespace-client browser callers. Empty array [] for non-namespace-client tokens. |
scopes | array of strings | Reserved for future optional scopes. MUST be [] in schema 1.0. |
prefix | string | First 14 characters of the token, retained for audit/debug display. |
created_by | string | User id or token id that created the token. |
created_at | string | RFC 3339 creation timestamp. |
expires_at | string | null | Expiration timestamp. null means no automatic expiration. |
last_used_at | string | null | Last successful authentication timestamp. |
last_used_ip_hash | string | null | Optional hashed client IP for abuse investigation without storing raw IPs. |
status | string | active, revoked, or expired. |
revoked_at | string | null | Revocation timestamp. |
revoked_by | string | null | User id or token id that revoked the token. |
Full token values are never stored. The server stores a keyed HMAC-SHA-256 digest of the full token using a server-side secret, plus the public prefix. Authentication performs a constant-time digest comparison. Rotating the server-side HMAC key requires reissuing service tokens.
Issuance rules
Issuance always enforces least privilege:
| Token to issue | Who can issue it |
|---|---|
namespace-read | Namespace admin for that flag namespace, tenant admin for the owning tenant, tenant-admin token for the owning tenant, or superadmin. |
namespace-write | Same as namespace-read. |
namespace-client | Same as namespace-read. The bound environment MUST exist in the flag namespace's current manifest at issuance time; public_evaluate = true is NOT required at issuance (the check is per-request). |
tenant-admin | Human tenant admin for that tenant or superadmin. A tenant-admin service token cannot mint another tenant-admin token. |
superadmin | Bootstrap/admin CLI on the server host, or an existing active superadmin token. |
A token cannot issue a token with broader installation visibility than itself. A namespace-bound token cannot issue any token. A token cannot grant optional scopes it does not already have, except that a human admin can grant scopes permitted by their role.
Namespace creation does NOT automatically issue service tokens. Create the flag namespace first, then create explicit tokens with names, owners, scopes, and expiration appropriate for the consumer.
Lifecycle
States
| State | Meaning |
|---|---|
active | Token authenticates if its scope permits the requested operation. |
expired | expires_at is in the past. Returns 401 Unauthorized. |
revoked | Manually revoked. Returns 401 Unauthorized. |
Expired and revoked tokens are retained as metadata for audit history until the installation's audit-retention policy removes them.
Expiration
expires_at is optional. Operators should prefer bounded lifetimes for automation tokens:
| Token type | Recommended max lifetime |
|---|---|
CI namespace-write | 90 days |
Runtime namespace-read SDK | 180 days, unless deployment rotation is difficult |
namespace-client | 30–90 days. Embedded in publicly-served assets; rotation requires a deploy. |
tenant-admin, superadmin | Shortest practical lifetime; prefer human login where possible. |
The server evaluates expiration on every request. Expiration does NOT delete the token record.
Revocation
Revocation is immediate. New requests using the token fail with 401 Unauthorized. In-flight requests that already passed authentication may complete normally.
Revoking a tenant or flag namespace deletes no tokens by itself, but tenant deletion or namespace deletion revokes all tokens bound to the deleted object.
Rotation
Rotation creates a replacement token with the same type, binding, scopes, and expiration policy unless overridden. The old token remains active until explicitly revoked. This supports zero-downtime credential rotation:
- Create or rotate to a new token.
- Update the consumer secret.
- Verify the consumer succeeds with the new token.
- Revoke the old token.
The server records rotated_from_token_id on the new token and rotated_to_token_id on the old token when rotation is performed through the rotation endpoint.
Audit events
The server records audit events for token lifecycle changes:
| Event | Trigger |
|---|---|
token.created | A service token is created. |
token.rotated | A replacement token is created through the rotation endpoint. |
token.revoked | A service token is revoked. |
token.expired | A token is first observed or swept after expires_at. |
token.authenticated | A token successfully authenticates. May be sampled or rate-limited; last_used_at is updated at most once per token per minute. |
Audit entries include token id, token prefix, token type, tenant / flag-namespace binding, actor id, timestamp, request id, and result. They never include the full token secret.
Token management API
Token-management endpoints live at the installation level because the token itself carries the tenant or flag-namespace binding.
POST /api/v1/tokens
Create a service token.
Auth required: see Issuance rules and access-control § Token management authorization.
Request body:
{
"type": "namespace-write",
"name": "payments-ci-upload",
"description": "GitHub Actions manifest upload for payments",
"tenant_slug": "acme",
"namespace_slug": "payments",
"expires_at": "2026-07-24T00:00:00Z"
}
| Field | Required | Description |
|---|---|---|
type | yes | Service-token type. |
name | yes | Human-readable name. |
description | no | Optional note shown in admin UI and audits. |
tenant_slug | conditional | Required for tenant-admin and every namespace token (namespace-read, namespace-write, namespace-client). Namespace slugs are unique per-tenant, not globally — tenant_slug is needed to disambiguate. Ignored for superadmin. |
namespace_slug | conditional | Required for namespace tokens (including namespace-client). Must be omitted for tenant-admin and superadmin. The named flag namespace must exist in tenant_slug. |
environment_slug | conditional | Required for namespace-client. Must be omitted for every other type. The named environment MUST exist in the flag namespace's current manifest. |
allowed_origins | conditional | Optional for namespace-client (defaults to []). Must be omitted or empty for every other type. Each entry is an Origin value; "*" is rejected. |
scopes | no | Reserved for future optional scopes. MUST be omitted or [] in schema 1.0. |
expires_at | no | Optional RFC 3339 expiration timestamp. |
Response: 201 Created
{
"token": {
"id": "tok_01HV5Z9R4Y0XJ7A2W8WJ9R7S3A",
"type": "namespace-write",
"name": "payments-ci-upload",
"tenant_slug": "acme",
"namespace_slug": "payments",
"scopes": [],
"prefix": "exd_write_7gHk",
"created_at": "2026-04-25T09:14:33Z",
"expires_at": "2026-07-24T00:00:00Z",
"last_used_at": null,
"status": "active"
},
"secret": "exd_write_7gHkLmN3pQrStUvWxYz2AbCdEfGhJkLmNpQr3s",
"request_id": "01HV5XK2GFQT8N3JRDCP7MW04B"
}
secret is shown only in this response.
GET /api/v1/tokens
List visible token metadata. Secrets are never returned.
Auth required: token.read for each returned token. Namespace-bound tokens cannot list tokens.
Query parameters:
| Parameter | Description |
|---|---|
tenant | Limit to a tenant. Required when the caller can see multiple tenants and is not superadmin. |
namespace | Limit to one flag namespace. |
type | Filter by service-token type. |
status | active, revoked, or expired. Defaults to active. |
limit, after | Pagination (see server-api § Pagination). |
GET /api/v1/tokens/{token_id}
Return metadata for one token visible to the caller. Secrets are never returned.
POST /api/v1/tokens/{token_id}/rotate
Create a replacement token.
Auth required: token.rotate on the token AND permission to create the replacement token type.
Request body:
{
"name": "payments-ci-upload-2026-q3",
"expires_at": "2026-10-22T00:00:00Z"
}
Omitted fields inherit from the old token. The response shape is the same as POST /api/v1/tokens and includes the new one-time secret.
DELETE /api/v1/tokens/{token_id}
Revoke a token immediately.
Auth required: token.revoke on the token, or the token itself for self-revocation. Namespace-bound tokens cannot revoke other tokens.
Response: 200 OK
{
"token": {
"id": "tok_01HV5Z9R4Y0XJ7A2W8WJ9R7S3A",
"status": "revoked",
"revoked_at": "2026-04-25T10:15:00Z"
},
"request_id": "01HV5XK2GFQT8N3JRDCP7MW04C"
}
Human session tokens
Human session tokens are issued by the configured tenant login flow:
- SSO tenants use the configured OIDC or SAML provider.
- Email-domain tenants use the installation's verified-email login flow.
Session tokens are short-lived bearer credentials. They encode or reference the authenticated user id, admitted tenants, session expiry, and login assurance. Effective authorization is computed on each request from current tenant-admin and namespace-admin membership (see access-control) — so revoking an admin role takes effect without waiting for the session to expire.
Human session refresh, cookie settings, MFA policy, and identity-provider configuration are installation concerns and are outside the service-token management API. They must still follow the same authorization outcomes documented in the server API.
See also
- access-control — permission matrix and per-endpoint authorization rules.
exd-server-admin token mint/list/revoke— the operator CLI for managing tokens without going through the HTTP API.- server-api § Authentication — the
Authorizationheader on every request.