Relay keys and host keys never meet on the wire: a caller presents a relay key, and Relay separately draws a host key from the pool to call the upstream. Your callers never see the upstream credential.
Teams, projects, and principals
Tenancy has three levels:- A team is the outer scope. It has no member list: a user belongs to a team through a role binding at team scope.
- A project belongs to one team. Policies, rate limits, host keys, service accounts, policy bindings and service-account keys can be owned by a project.
- A principal is who a request acts as: a user (a person, from local config, SSO or SCIM) or a service account (a machine principal that lives in one project).
spec.principal ({kind: serviceaccount | user, ...}). Rotating, revoking or deleting a key leaves the principal and everything bound to it untouched.
- A service account’s key belongs to the account’s project. Its requests are attributed to that project and team.
- A user’s key (a personal key) belongs to the user. It carries no project unless its own policy is owned by a project; then its requests carry that project’s attribution and limits.
- An inference token is minted for a user in one project, and its requests are attributed to that project.
policyId, else the service account’s policyId, else the first of the project’s policy bindings that names one of the principal’s subjects, ordered by priority (lower wins, default 100) and then by name. A project-scoped credential that resolves no policy is refused with 403. A personal key with no project and no policy is served only under RELAY_AUTHZ=single with allowMissingPolicy: true in the inference settings section.
Teams, projects, service accounts, keys and bindings are ordinary resources: create them through the control API or declare them as manifests and apply them from git.
Relay keys
A relay key (sk-wr-…) is the bearer your application uses. Relay generates the plaintext from crypto/rand and stores only sha256(plaintext) and a short display prefix. The plaintext is returned once, on creation. Each request is authenticated by hashing the bearer and looking it up in memory; no database call is made.
A key stops authenticating when it is disabled, revoked (revokedAt), past expiresAt, or owned by a disabled user. Rotating a key (POST /api/keys/by-id/{id}/rotate) can keep the previous value working for graceSeconds, capped by keyRotateMaxGrace in the inference settings section (default 24 hours). To mint a key without sending the plaintext through the server, use relay keygen and put only the hash in a manifest.
Inference tokens
Every inference route also accepts a relay-signed inference token in the sameAuthorization: Bearer header. Relay tells the two apart by shape: a token looks like a JWT (ey…, two . separators) and a key never does.
A token is minted by a signed-in user who holds tokens.mint in the project. It names that user and project and carries no secret beyond its signature, so a leaked token exposes only what that user’s bindings already grant inside that project. The admin bearer names no user and cannot mint one.
relay token mint --project my-project --user alice logs in and mints in one step; see the CLI reference.
The auth:tokens settings section controls tokens: enabled (default true), defaultTTL (default 1 hour, used when a mint names no ttl) and maxTTL (default 24 hours), both in nanoseconds. An admin changes it with PUT /api/settings/auth:tokens. Tokens need RELAY_MASTER_KEY: the signing key is generated on first boot and stored encrypted under it, and a deployment without a master key mints no tokens. One user may mint 30 tokens per minute. POST /api/auth/token/keys/rotate replaces the signing key and takes the right to update settings; tokens signed by the previous key keep verifying until they expire.
Revoking a token
From narrowest to widest:POST /api/auth/token/revokewith{"token": "<token>"}: revokes the presented token. No session is needed.DELETE /api/auth/token/{jti}: revokes one token by its id. The mint is read from the audit log, and the caller needstokens.revokeat the token’s project; the user who minted it always may. Only the fallback for a mint no longer in the log, passingteam_idandexp, requires an admin.POST /api/auth/token/revoke-all: bumps the caller’s owntoken_version, invalidating every token they hold. A caller withusers.updateat global scope can do the same for another user withPOST /api/users/by-id/{id}/revoke-tokens, and disabling an account bumps it too.
401. A revoked token, by jti or by token_version, carries the code token_revoked; an expired token carries unauthenticated with the message token expired.
Host keys
A host key is an upstream credential, such as your OpenAI or Anthropic API key. Relay holds its value in one of two modes:RELAY_ or WYOLET_ are refused, because a host key’s value is sent to its host and those names hold Relay’s own configuration.
A stored value is encrypted under a data key of the host key’s owner, and the data keys are wrapped by RELAY_MASTER_KEY; only ciphertext reaches the database, and reads never return the value. POST /api/host-keys/by-id/{id}/rotate replaces a stored value. See Stored secrets and key rotation for the scheme and the rotation procedures.
The control plane
The control API (/api/*) and the admin UI accept either:
- a session cookie, set by
POST /api/auth/loginand backed by server-side sessions: an opaque token, rotated on login and destroyed on logout; or - the admin bearer,
RELAY_ADMIN_TOKEN: a break-glass credential for operators and CI that works alongside sessions.
$2a$, $2b$, $2y$). A plain-text password still works and logs a deprecation warning. For single sign-on, see SSO.
An account is disabled with PUT /api/users/by-id/{id} and {"disabled": true}. It is signed out on its next request and refused at login, and its personal keys and inference tokens stop working at once. The control plane answers it 401 with the code account_disabled.
Run the control plane on its own port (RELAY_CONTROL_PORT) behind your network controls, and expose only the inference plane publicly.
Authorization
RELAY_AUTHZ sets how the control plane authorizes a caller:
The admin bearer and any user holding the
admin role pass every check in both modes, so a new rbac deployment cannot lock itself out.
Use rbac as soon as more than one person signs in.
RELAY_MULTI_USER is deprecated. When RELAY_AUTHZ is unset, a truthy RELAY_MULTI_USER is read as RELAY_AUTHZ=rbac with a startup warning. Set RELAY_AUTHZ explicitly.The model
- A Role is a set of
(kinds, verbs)rules. It carries no scope. - A RoleBinding grants one role to subjects (users, groups or service accounts) at one scope: a project, a team, or global (
{kind: system}). - Every resource has a scope chain: a project-owned row lives in
[project, team, global], a team-owned row in[team, global], and catalog and personal rows in[global]. - A binding at scope S grants its role on every resource whose chain contains S. A team binding covers every project in the team, current and future; a project binding never reaches a sibling project.
- Personal rows. A row owned by
{kind: user, id: <you>}grants you every verb on it. Keys, policies, host keys and rate limits can be personal. Providers, hosts, models, host bindings and pricings are shared catalog data that no user, team or project can own: writing one takesadminorcatalog-editor, and a request that names such an owner for one is refused with400. - Catalog reads. Providers, hosts, models, host bindings, pricings and system rate limits are readable by any authenticated caller. Changing them needs a binding and still passes the governance settings.
[global], so only an admin can reach it to repair it.
Built-in roles
Seeded as system rows on boot. Bind them; they cannot be edited through the API.Writing bindings
Roles and bindings are manifests like any other resource: apply them from a repository or create them through the API.custom-roles. Its name must not be a built-in role’s name:
kinds are audit debug groups host-bindings host-keys hosts keys license logs master-key models policies policy-bindings pricings projects providers rate-limits role-bindings roles scim-clients secret-keys service-accounts settings system teams tokens usage users, plus *. verbs are apply attach create delete destroy detach generate get health list mint read reload revoke rewrap rotate snapshot update, plus *.
Groups
A binding names a group by name. The name is matched by a localGroup row and by a group claim your identity provider asserts at sign-in; the two combine, so bindings keep working when you move from local groups to SSO. Three virtual groups always exist without a row: system:authenticated, system:serviceaccounts, and system:serviceaccounts:<project>. Changing a local group’s metadata.name through the control API is refused with 409 resource_in_use while a role or policy binding names the old name; remove the group from those bindings or point them at the new name first. Its displayName can change freely.
Reads
List endpoints return only the rows the caller couldget, and a row they may not see answers 404, not 403, so its existence is not revealed. Usage and request logs narrow the same way: a caller with a global binding reads every event; everyone else reads the events of the projects they can see and the events their own keys produced.
Checking access
POST /api/access-review answers whether a user or service account may perform an action on a resource, and which role binding or rule decides it, without performing the action. Leave out the subject to check yourself, which any signed-in caller may do. Checking anyone else takes an admin and writes an access-review.create audit row.
0 when the action is allowed, 1 when it is denied, and 2 when the request fails. A bare verb takes the resource’s kind. To ask about a list or a create, name the collection and its owner (keys --owner project/ml); -o json prints the full answer. Over HTTP:
reason is admin, rule (one of the shortcuts above, such as personal-row or catalog-read), binding (with the binding, role and scope that matched), or none, alongside the resource’s scope chain. It covers authorization only: an allowed request can still fail validation, a governance setting or a reference check. A resource you cannot see answers 404, as its own routes do. Another user is checked with their roles and local groups; groups an identity provider asserts live only in that user’s session and are not part of the answer. The endpoint needs RELAY_AUTHZ=rbac and answers 501 under single.