Authentication

API keys, session cookies, and OIDC access tokens, plus the headers that select the organization, space, and end-user.

Appstrate accepts three kinds of credentials. They are tried in this order and the first match wins:

OrderCredentialFormatTypical use
1OIDC access token (oidc module)Authorization: Bearer ey... (JWT)The CLI, second-party dashboards, your own end-users
2API keyAuthorization: Bearer apst_...Backends, scripts, CI/CD
3Session cookie (Better Auth)better-auth.session_token cookieThe dashboard

A request with none of them is 401 unauthorized, with a WWW-Authenticate: Bearer challenge.

Context headers

An organization contains spaces, and most resources (agents, runs, schedules, end-users, API keys, files, packages, integrations) live in a space. Each credential carries some of that context itself and needs the headers for the rest.

HeaderUsed withDescription
AuthorizationAPI keys, OIDC tokensBearer apst_... or Bearer ey...
X-Org-IdSession cookies, instance-level OIDC tokensSelects the organization. Ignored with an API key: the key is bound to its organization, and only X-Space-Id is checked
X-Space-IdSession cookies, instance-level OIDC tokensSelects the space on space-scoped routes. An API key is already pinned to one space. If they differ, the answer is 403. If a space-scoped route gets none, the answer is 400 with param: "X-Space-Id"
Appstrate-UserAPI keys onlyeu_... end-user id. Run the request on behalf of that end-user. See End-user impersonation
Appstrate-VersionOptionalDate version of the API, YYYY-MM-DD. See API Reference
Idempotency-KeyOperations that declare itSee Idempotency
X-View-AsSessions (and instance-level OIDC tokens) of owners and adminsPreview the API as a lesser role. Refused with 400 view_as_unsupported on API keys

The routes that need a space are the ones under /api/agents, /api/runs, /api/schedules, /api/end-users, /api/api-keys, /api/notifications, /api/packages, /api/integrations, /api/uploads, and /api/files. Organization-level routes such as /api/models need X-Org-Id only, and the /api/orgs/{orgId} family carries the organization in the path. The parameters of each operation in the OpenAPI document say which headers it takes.

API keys

API keys are the credential for servers and automation. A key is apst_ followed by 30 random base62 characters and a 6-character checksum, so a leaked key can be recognized by a secret scanner and a malformed one is refused before any lookup. Appstrate stores only a SHA-256 hash and a display prefix (apst_ plus 8 characters). Older ask_ keys no longer authenticate.

Create a key

In the dashboard: Organization settings, API Keys under Space, New API key. Over the API, from a session that holds api-keys:create. An API key cannot mint another key, because api-keys:* is not a grantable scope:

curl -X POST http://localhost:3000/api/api-keys \
  -b cookies.txt \
  -H "X-Org-Id: $ORG_ID" \
  -H "X-Space-Id: $SPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production backend",
    "scopes": ["agents:read", "agents:run", "runs:read"],
    "expiresAt": "2027-01-01T00:00:00Z"
  }'
{
  "id": "…",
  "key": "apst_…",
  "keyPrefix": "apst_AbCd1234",
  "scopes": ["agents:read", "agents:run", "runs:read"]
}

The full key is returned once. Store it immediately.

The X-Org-Id and X-Space-Id headers choose the space the key is created in (see Session cookies for the sign-in that produces cookies.txt). A personal space takes no API keys (409 personal_space_takes_no_keys): create the key in a team space.

What a key can do

A key is pinned to the space it was created in and acts with a ceiling:

effective permissions = key scopes  ∩  what the key's creator currently holds in that space
  • The scopes are a ceiling, not a grant. The key never has more than its creator, and it follows the creator: if they lose a role or leave the organization, their keys lose that authority, and leaving the organization revokes them.
  • Scopes you request but your own role does not hold are silently dropped from the new key. A scope no key can carry at all is 400 invalid_request.
  • If you omit scopes or send an empty list, the key receives every grantable scope you hold, which includes credential-proxy:call and llm-proxy:call when you hold them. A non-empty scopes list mints a narrower key. Prefer an explicit list.
  • expiresAt is an ISO 8601 date in the future, or null for no expiry. There is no rotation: create a new key, switch over, then revoke the old one with DELETE /api/api-keys/{id}. Revocation is immediate.

Scopes

Scopes are resource:action strings. GET /api/api-keys/available-scopes lists the ones you can grant. The built-in set:

ResourceScopes
Agentsagents:read, agents:write, agents:configure, agents:delete, agents:run
Skillsskills:read, skills:write, skills:delete
MCP serversmcp-servers:read, mcp-servers:write, mcp-servers:delete
Integrationsintegrations:read, integrations:write, integrations:delete, integrations:install, integrations:uninstall, integrations:connect, integrations:disconnect
Runsruns:read, runs:read-all, runs:cancel, runs:delete
Filesfiles:read, files:delete
Schedulesschedules:read, schedules:write, schedules:delete
Models and proxiesmodels:read, models:write, models:delete, proxies:read, proxies:write, proxies:delete
Spaces and end-usersspaces:read, spaces:write, spaces:delete, end-users:read, end-users:write, end-users:delete
Remote executioncredential-proxy:call, llm-proxy:call

Loaded modules add their own: webhooks:read|write|delete (webhooks module), oauth-clients:read|write|delete (OIDC module), and mcp:read, mcp:invoke (MCP module). credential-proxy:call and llm-proxy:call let a key use stored credentials and model access from outside the platform (the CLI and the GitHub Action); grant them deliberately. The RBAC model behind all of this is in the RBAC specification.

Server-Sent Events

EventSource cannot send headers. On the realtime routes only, pass the key as a query parameter:

GET /api/realtime/runs/{id}?token=apst_...

Other routes only read the Authorization header. See Realtime streams.

Session cookies

The dashboard signs users in with Better Auth: email and password, with optional Google and GitHub sign-in, magic links, and email verification depending on how the instance is configured.

curl -X POST http://localhost:3000/api/auth/sign-in/email \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "password": "…" }' \
  -c cookies.txt

curl http://localhost:3000/api/agents \
  -b cookies.txt \
  -H "X-Org-Id: $ORG_ID" \
  -H "X-Space-Id: $SPACE_ID"

When NODE_ENV is production, sign-in, sign-up and the other Better Auth routes are rate limited per client IP address; a refused call is a 429 with a JSON body. A session does not pin an organization or a space, so send both headers. Sessions have a realm: platform for dashboard users, end_user:<spaceId> for people who signed in through your OIDC flow. Platform routes refuse end-user sessions.

A session expires after 7 days. While it is used it is extended, at most once a day, and the response carries a refreshed cookie, so an active user stays signed in.

Changing a password ends the account's other sessions and keeps the one that made the change. A reset (/api/auth/reset-password, or the hosted reset page of the OIDC module) ends every session. Both also invalidate pending reset links, magic links and social-account links, and, with the OIDC module, the account's OAuth refresh and access tokens, CLI sessions and device codes, so MCP clients and the CLI have to sign in again. API keys are not revoked, and an OAuth access token already issued as a JWT stays valid until it expires (one hour by default). The full list of what is deliberately kept is in SECURITY.md. If the revocation fails, the request answers 500 with the code credential_change_revocation_failed instead of reporting success.

OIDC access tokens

The oidc module (in the default MODULES set) turns Appstrate into an OAuth 2.1 and OpenID Connect authorization server. It issues ES256-signed JWTs, accepted as Authorization: Bearer ey.... The kind of token depends on the level of the OAuth client that issued it:

Client levelToken representsContext
instanceA platform user acting as themselves (the appstrate CLI via device flow, first-party dashboards)Send X-Org-Id (and X-Space-Id where needed) like a session
orgA platform user, delegated to a third-party appBound to one organization. Permissions are the token's scopes intersected with the user's current role
spaceOne of your end-users, in one spaceBound to the space. Only a fixed allowlist of scopes can be granted

Endpoints:

PurposeEndpoint
DiscoveryGET /.well-known/openid-configuration and GET /.well-known/oauth-authorization-server
Authorize (Authorization Code + PKCE)GET /api/auth/oauth2/authorize
TokenPOST /api/auth/oauth2/token
UserInfo, introspection, revocation/api/auth/oauth2/userinfo, /introspect, /revoke
Signing keysGET /api/auth/jwks
Device flow (RFC 8628)POST /api/auth/device/code
Register and manage clientsPOST /api/oauth/clients, GET /api/oauth/clients, and siblings (needs oauth-clients:*)

OAuth clients are registered and managed only through the /api/oauth/clients routes, under the oauth-clients:* permissions and the confinement of their level. The client endpoints of the underlying library (/api/auth/oauth2/create-client, get-client, get-clients, update-client, delete-client and client/rotate-secret) answer 401 to every session, so a signed-in user cannot mint or read a client through them. Dynamic client registration, which needs no session, is unchanged. The authorize, token, introspect, revoke and registration endpoints are rate limited per IP address, and a call refused by that limit is a 429 with a Retry-After header and an RFC 6749 body ({ "error": "temporarily_unavailable", "error_description": "..." }), not a problem document.

A space-level client is how you embed Appstrate sign-in in your product: your user logs in, your app receives a token, and every call it makes is scoped to that end-user. Whether a first sign-in creates the end-user is controlled per client (allowSignup, closed by default). To register clients, embed the flow and set up per-space SMTP and social sign-in, see Embedded sign-in. The module's internals are in its README.

OIDC tokens are not accepted by the ?token= query parameter of the SSE routes, which takes API keys only. Use a cookie session or an API key for realtime streams.

End-user impersonation

When you embed Appstrate in a product, your backend holds one API key and acts for many end-users. Impersonation is a header on an API-key request, not a separate credential:

Authorization: Bearer apst_...
Appstrate-User: eu_...
  • Only API keys can impersonate. With a session cookie or an OIDC token the answer is 400 header_not_allowed.
  • The value must start with eu_ (400 invalid_end_user_id) and name an end-user of the key's space (403 invalid_end_user).
  • Runs, memory, and integration connections used by the request belong to that end-user, and end-users only see their own runs.
  • Every impersonated request is logged server-side with the request id, key id, end-user id, method, path, IP, and user agent.

See Multi-tenancy and End-users.

Errors

StatuscodeMeaning
401unauthorizedNo credential, an invalid or expired key, or no valid session
403forbiddenThe credential is valid but lacks the permission, or a context header contradicts the credential
403not_a_space_memberThe caller holds no role in the requested space
400header_not_allowedA header such as Appstrate-User was sent with a credential that cannot honor it
429rate_limitedToo many requests. See API Reference

All errors are RFC 9457 problem documents. See Errors.

On this page