Skip to main content
Relay has four kinds of caller, and each authenticates differently. 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).
A relay key is a credential of one principal, named in 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.
The policy that governs a request is the credential’s own 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 same Authorization: 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/revoke with {"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 needs tokens.revoke at the token’s project; the user who minted it always may. Only the fallback for a mint no longer in the log, passing team_id and exp, requires an admin.
  • POST /api/auth/token/revoke-all: bumps the caller’s own token_version, invalidating every token they hold. A caller with users.update at global scope can do the same for another user with POST /api/users/by-id/{id}/revoke-tokens, and disabling an account bumps it too.
Rejections answer 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:
An env reference is read from the relay process’s environment. Names starting with 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.
RELAY_MASTER_KEY must stay the same across restarts: it unwraps the data keys your stored host keys are encrypted under. Losing it, or replacing it without following Changing the master key, makes existing stored secrets unreadable.

The control plane

The control API (/api/*) and the admin UI accept either:
  • a session cookie, set by POST /api/auth/login and 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.
Local account passwords may be bcrypt hashes ($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.
Under single, OIDC with registration: open makes every user your identity provider can authenticate an admin. Relay logs a warning at startup for this pairing; use rbac or registration: closed.

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.
Two rules need no binding:
  • 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 takes admin or catalog-editor, and a request that names such an owner for one is refused with 400.
  • 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.
A row whose project or team is disabled or deleted falls back to [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.
A custom role, when the built-ins don’t fit, needs a license with 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 local Group 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 could get, 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.
The command exits 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:
The answer’s 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.