Skip to content
rise.Operator docs
GitHub

Authentication & Tokens

Rise both issues its own JWTs and accepts JWTs from external identity providers. This page is the operator’s map of that token model: what each token is for, which configuration and endpoints govern it, and the security implications you need to plan for. For the exact key names and YAML, see the Configuration Guide; for the internal algorithm/claim disambiguation rules, see the engineering reference in crates/rise-backend-auth/README.md.

TokenDirectionAlgorithmAudience (aud)Verified byGoverned by
SessionIssuedHS256Rise public_urlRise API middlewareserver.jwt_signing_secret, server.jwt_expiry_seconds
AccessIssuedHS256Rise public_urlRise API middlewareserver.auth_token_max_ttl_seconds, auth.allow_raw_external_tokens
IdentityIssuedRS256Rise public_url, or a caller-named external verifierRise API middleware (re-resolved against the resource store per request), or the external verifier via Rise JWKSserver.rs256_private_key_pem, server.auth_token_max_ttl_seconds; trust policies are resources
IngressIssuedRS256Project URLNginx/ingress via Rise JWKSserver.rs256_private_key_pem
Workload identityIssuedRS256Caller-supplied (e.g. sts.amazonaws.com)External system via Rise JWKSserver.rs256_private_key_pem, deployment_controller.identity_token_ttl_seconds
User login (OIDC)Acceptedper IdP—Rise (JWKS of auth.issuer)auth.issuer, auth.client_id, auth.client_secret
Service accountAcceptedRS256 (JWKS)project-scopedRise (JWKS of the SA issuer)per-project (CLI/API managed)
ControllerAcceptedRS256 (JWKS)per policyRise (JWKS of the trust policy’s issuer)Controller + ControllerTrustPolicy resources

Rise acts as an OIDC issuer for the RS256 tokens below. External systems verify them against Rise’s public key, published at:

  • JWKS: GET {public_url}/api/v1/auth/jwks
  • OIDC discovery: GET {public_url}/.well-known/openid-configuration (served at the root, per the OIDC spec)

The token a user (or the CLI) holds after logging in; it authenticates requests to the Rise API and UI. It is symmetric (HS256), signed with server.jwt_signing_secret (base64, ≥ 32 bytes), scoped to the Rise public_url audience, and carries the claims listed in server.jwt_claims. Lifetime is server.jwt_expiry_seconds (default 24h). Session tokens are an internal concern — they are never verified outside Rise.

A session names the user’s Rise User resource: its header typ is rise-session+jwt, sub is the canonical user:<name>, rise_uid is the User’s UID, and rise_identity_uid is the UserIdentity the login came through. rise_client records what the session was issued to: browser (the web UI’s sign-in) or cli (rise login, with either flow). Every request re-resolves the User and identity:

  • spec.active: false on the User, or deleting it, ends every session issued for it;
  • spec.active: false on one UserIdentity, or deleting it, ends the sessions minted through that identity, and leaves the User’s other logins alone.

email stays in the token for the typed APIs. A session issued before this shape (default typ, the IdP’s sub, no rise_uid) keeps working on the typed APIs until it expires, and ends early once any login has mapped its IdP sub and that identity or its User is disabled; the generic resource API refuses it with 401 — log in again. If the store cannot be reached to re-check a session, the request fails with 500 rather than logging the user out.

A short-lived, Rise-issued token that encodes a fully-resolved principal (a service account or a controller). It is minted by the RFC 8693 token-exchange endpoint and is symmetric (HS256, aud = Rise public_url), so — like Session tokens — it is verified only inside Rise and never by external parties. It is distinguished from a Session token by its JWT header typ (rise-access+jwt).

Why it exists. Without it, a CI service account presents its external OIDC token on every request and Rise re-resolves the service account per request (issuer lookups, JWKS verification, and claim matching in the hot path). With token exchange, the caller resolves once, up front, and every subsequent request checks only that the embedded service account still stands as the token says.

Exchange endpoint: POST /api/v1/auth/token (public — the subject token is the credential). It follows RFC 8693:

// request
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<external OIDC JWT>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"identity": "my-project+0@sa.rise.local" // the identity to assume (a service
// account's email); omit for a controller
}
// response
{
"access_token": "<rise HS256 jwt>",
"token_type": "Bearer",
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
"expires_in": 600
}

identity is a selector (which service account to assume); the subject token is the proof. The exchange looks the SA up by that email, verifies the inner OIDC token exactly as the accept paths below do (issuer guard → SA issuer match → JWKS signature + exp → the SA’s claim matching), then mints the access token. With an identity it resolves a project service account (its project is derived from the SA); without one, a controller identity. Every identity-dependent failure (unknown email, not an SA, wrong issuer, claim mismatch) returns the same coarse invalid_grant so SA emails cannot be enumerated. Lifetime is server.auth_token_max_ttl_seconds (default 600s). Each request re-reads the service account the token names: deleting the SA, or changing its environment restriction, rejects its outstanding tokens immediately. A change to the SA’s issuer or claims applies to the next exchange.

This STS-style endpoint is distinct from the OIDC token_endpoint (the authorization-code exchange at /api/v1/auth/code/exchange); the discovery document continues to advertise the latter.

This endpoint resolves the typed service accounts and configured controllers that the project APIs use today. Its resource-model successor is the token subresource below; the two coexist until the typed-object migration moves service accounts onto the resource store.

Identity (RS256) — the token subresource

Section titled “Identity (RS256) — the token subresource”

A short-lived, Rise-issued token whose subject is a ServiceAccount or Controller resource of the generic resource API (serviceaccount:<org>/<name> or controller:<name>), carrying that resource’s UID as rise_uid. It is minted only by the target’s own token subresource — POST /api/v1/resources/rise.dev/v1alpha1/serviceaccounts/{org}/{name}/token or …/controllers/{name}/token — in one of two disjoint modes: workload token exchange, where an external OIDC JWT in the body is validated against the ServiceAccountTrustPolicy / ControllerTrustPolicy children of that exact target; or delegated issuance, where an already-authenticated Rise principal holding (create, <kind>, token) on the target mints for it.

It is always RS256, signed with the same key as ingress and workload tokens and distinguished from them by its header typ (rise-identity+jwt). The request may name an audience: omitted, the token is for Rise’s own API, which is the only audience the API accepts; any other value mints the same token for that external verifier, which checks it against the JWKS above. One key and one token kind serve both, which is why the key is load-bearing for the resource API as well.

What it carries and what it does not. Identity and an optional ceiling, never a snapshot of grants. Rise re-resolves (sub, rise_uid) to one live resource on every request and evaluates the identity’s current RBAC, intersected with the token’s RFC 9396 authorization_details if present. So:

  • deleting the target, or a draining Organization above it, fails every outstanding token immediately — there is nothing to revoke;
  • recreating the same name under a new UID does not revive old tokens;
  • narrowing the target’s Roles takes effect on the next request;
  • revoking the caller’s token-create grant stops new issuance only.

Delegated tokens record their delegators in a bounded act chain (at most four), for audit; actor data never grants access. Lifetime is capped by server.auth_token_max_ttl_seconds, the platform-global maximum shared with Access tokens. Trust policies are ordinary governed resources: they hold public matching configuration (issuer, aud, further string claims with * globs), never keys, and may not name Rise’s own issuer. Every failure of a workload exchange after the route is entered is one coarse 401, so the route confirms nothing about which identities or policies exist. The request/response shape and the authorization rules are specified in the resource HTTP API.

Migrating off raw tokens (auth.allow_raw_external_tokens). Defaults to true: a service account may still present its raw external OIDC token directly to project-scoped endpoints (the legacy per-request path), which Rise resolves as before. Set it to false to require pre-exchange — callers must obtain an access token from /api/v1/auth/token first. While it is true, every accepted raw-token request (after JWKS validation) emits one metric-shaped tracing event (target=rise::deprecation, metric=raw_external_token) carrying the validated issuer/sub (and aud) — aggregate it in your log pipeline (count, group by issuer/sub) to see which workload identities still need migrating, not just their issuer. Leaving it true forfeits the security benefits above. Defaults to false starting in 0.25.0; migrate CI to pre-exchange before upgrading.

Issued for private project access enforcement at the ingress layer (see Private Project Authentication). It is asymmetric (RS256) so the ingress can verify it via Rise’s JWKS without holding a shared secret, and its audience is the project URL. Minted by GET /api/v1/auth/ingress.

Issued to deployed apps so they can federate identity to external systems (AWS STS, GCP Workload Identity Federation, HashiCorp Vault, …) without long-lived secrets. The subject describes the Rise identity (rise:proj:<project>:env:<environment>), the audience is supplied per request by the caller, and the token is signed with the same RS256 key as ingress tokens — so the JWKS/discovery endpoints above already cover verification.

Two paths produce these tokens:

  • The controller auto-mints them and projects them into pods under /var/run/secrets/rise/tokens/. Lifetime is deployment_controller.identity_token_ttl_seconds (default 3600s); the controller re-mints once a token passes half its lifetime.
  • Apps may also call POST /api/v1/identity/token directly, exchanging their deployment bootstrap credential (Authorization: Bearer <credential>) for a token with a requested audience. Its lifetime is capped by the same identity_token_ttl_seconds — one cap for every workload identity token — and defaults to it. On ECS the identity sidecar writes the token files through this endpoint.

The RS256 key is operationally load-bearing

Section titled “The RS256 key is operationally load-bearing”

server.rs256_private_key_pem signs ingress, workload, and identity tokens and backs the JWKS. If you do not configure it, Rise generates a fresh key pair on every start — which silently invalidates all previously issued ingress, workload, and identity tokens, differs between replicas, and rotates the JWKS out from under any external verifier.

  • Always set rs256_private_key_pem (and optionally rs256_public_key_pem, otherwise derived) in any non-ephemeral deployment.
  • Treat it as a secret; store it via your secret manager, not in plain config.
  • Rotating it invalidates outstanding ingress/workload tokens and changes the published JWKS — coordinate with anything that has cached the keys.

When Rise receives a bearer token whose issuer is not Rise itself, it discovers that issuer’s JWKS via {issuer}/.well-known/openid-configuration (SSRF-validated) and verifies the RS256 signature, iss, and exp before applying per-identity claim constraints.

Interactive login federates to your IdP via auth.issuer / auth.client_id / auth.client_secret / auth.scopes. On success Rise mints a Session token (above). This is how operators wire Rise to Dex, Okta, Entra ID, Google, etc.

Browser sign-in uses a short-lived, host-only nonce cookie in addition to PKCE and single-use OAuth state. The central callback requires the proof before exchanging the code. App sign-in establishes an independent proof on the app host, then visits Rise’s central host before the IdP redirect. The final /.rise/auth/complete step requires the app-host proof and the transaction’s exact app origin. A callback or completion URL alone cannot establish a browser session. Rise must successfully consume the transaction in PostgreSQL before returning a session cookie or app completion redirect; a storage failure refuses the response. Each host reuses one nonce per stage across concurrent sign-ins and refreshes its ten-minute expiry on each start. Completing one transaction leaves the nonce available to other pending transactions. Browsers must accept the proof cookies; expired or interrupted sign-ins restart from the sign-in page. Simultaneous first starts without a nonce can require restarting one sign-in.

Each login is resolved to a Rise User resource by the ID token’s exact (iss, sub) pair, recorded in a UserIdentity child of the User (ADR-0001 §1):

  • First login. No live UserIdentity holds the pair, so Rise creates a new User named u-<ulid> and its first UserIdentity in one transaction. Concurrent first logins converge on one User. The new User holds no grants of its own.
  • Returning login. The pair resolves to its User. Email never links accounts: a different sub with the same email is a different User.
  • Disabled. An inactive UserIdentity, or an active one under an inactive User, refuses the login (403); it is never treated as unknown. Deleting the UserIdentity instead only unlinks it — the next login provisions a fresh User with a new UID — so disable with spec.active: false.

auth.issuer must be spelled exactly as the IdP stamps iss — with or without its trailing slash, as the IdP does — and otherwise canonical (lower-case host, no default port), or the server refuses to start. The IdP must issue a stable sub.

There is deliberately no way to trade an ID token for a session outside these login flows: they receive the ID token from the IdP over the back channel, bound to the login by PKCE, state, and nonce, while an endpoint accepting presented ID tokens would honor any token the IdP issues for Rise’s client, from any flow.

Rise is the RFC 8628 authorization server itself; the IdP needs no device grant.

  1. The CLI calls POST /api/v1/auth/authorize {"flow": "device"} and gets a device code, a user code (XXXX-XXXX, from the RFC’s consonant alphabet) and verification_uri_complete = {public_url}/device?user_code=…. The row lands in device_authorizations, holding only a SHA-256 of the device code; it expires after 10 minutes. The endpoint is unauthenticated, so starts count against server.oauth_rate_limit (per client IP, and in their own bucket).
  2. The user opens /device in the Rise UI, signs in the usual way, and approves (POST /api/v1/auth/device/approve) or denies. Approval requires a browser session (rise_client: browser) that names its User and identity and was issued at most 10 minutes ago. Anything else gets 401, and the page sends the user through /auth/signin/start first. The approval records that session’s User UID and UserIdentity UID.
  3. The CLI polls POST /api/v1/auth/device/exchange. Answers are the RFC’s authorization_pending, slow_down (the interval grows by 5s), access_denied and expired_token, plus server_error for a transient failure. An approved row is deleted as it is redeemed, so it yields one session. The User and identity are re-resolved first, and the session is minted for exactly that pair. Disabling either one ends the device session, just like the browser session that approved it.

No IdP token is presented at any step. Every device session traces back to a recent browser login that was bound by PKCE, state and nonce. Device sessions are cli sessions, and so are those from rise login’s code flow, so a CLI session can never approve a device login. That means it cannot mint its own successor and outlive the IdP login.

Project-scoped identities for automation (e.g. pipelines) that present an OIDC JWT from a trusted CI issuer. They are managed per project through the CLI/API rather than static config; Rise validates the token against the issuer’s JWKS and the service account’s configured claim constraints. See the service-account workflow in the Rise user documentation.

Trusted external controllers authenticate with OIDC JWTs matched against live ControllerTrustPolicy resources rather than static config. An operator creates a root-scoped Controller resource for the identity, then one or more ControllerTrustPolicy children under it, each declaring an issuer and required claims (aud is mandatory; add sub/scope/etc. as needed; * wildcards are supported). Several trust policies matching the same Controller is an ordinary match — a Controller may accept more than one issuer or claim shape; policies matching different Controllers make a token ambiguous and the request is refused with 409. spec.issuer must equal the token’s iss byte-for-byte, matching UserIdentity.

A Controller authenticates against the generic resource API (/api/v1/resources) as the ordinary principal controller:<name>. What it may do is decided by RBAC alone: grant it update on the status and finalizers subresources of a kind (or any other verb) through a PlatformRoleBinding naming that subject. See Controller authorization for the full model and a worked example. A controller may also exchange its OIDC JWT once, at its own token subresource, for an identity token that carries the same controller:<name> principal.

  • server.jwt_signing_secret is a unique, ≥ 32-byte base64 secret (rotating it invalidates all sessions).
  • server.rs256_private_key_pem is set and persisted (see above).
  • server.cookie_secure: true and an https:// public_url in production.
  • Every accepted issuer (auth.issuer, service accounts, controllers) is one you control or explicitly trust; constrain aud (and sub where possible).
  • Workload aud values are the specific external systems you intend (avoid broad audiences); deployment_controller.identity_token_ttl_seconds is as short as your federation tolerates.