Authentication
Most CairnCMS requests need an access token for anything that maps onto a non-Public role’s permissions. Endpoints covered by the Public role’s permissions are reachable without one. The platform issues two kinds of tokens: short-lived JSON Web Tokens for interactive sessions, and long-lived static tokens for service accounts. This page covers both, the login and refresh flow that produces JWTs, and the SSO and two-factor surfaces that sit on top.
Token types
Section titled “Token types”CairnCMS recognizes two token shapes at request time:
- Access tokens (JWT). Signed with
SECRET, short-lived, issued by/auth/loginand rotated by/auth/refresh. Carry the user’s role and access flags inline so the request can be authorized without an extra database lookup. Default TTL is15m, configurable viaACCESS_TOKEN_TTL. - Static tokens. Stored as plain text on the user record (
directus_users.token), never expire, and require a database lookup on every request. Intended for service accounts where rotation is operator-managed rather than handled by a refresh flow.
The middleware identifies which shape was sent by inspecting the token: anything that parses as a CairnCMS-issued JWT is treated as a JWT; anything else is looked up against directus_users.token. There is no separate header or scheme.
Attaching a token
Section titled “Attaching a token”Two ways to send a token:
- Authorization header —
Authorization: Bearer <token>. Preferred for server-to-server calls and any context where you control the request headers. The platform follows the bearer scheme exactly: case-insensitive scheme name, single space, then the token. access_tokenquery parameter —?access_token=<token>. Supported for compatibility with clients that cannot set headers (legacy integrations, browser features like<img>tags that point at protected assets). URLs carrying tokens are routinely captured by browser history, server and CDN logs, andRefererheaders; avoid when an Authorization header is available.
If both are present, the header takes precedence. There is no documented behavior for sending different tokens through both so it’s not recommended.
Same-origin browser clients accessing /assets/* do not need to attach a token at all. The refresh-token cookie set during login is used to look up the session and authorize the asset request, which is the path the first-party admin app uses for protected asset loads. Other first-party API calls (including export downloads) send an Authorization header on a same-origin fetch and consume the response body directly, so bearer tokens still do not appear in browser URLs.
Endpoints that map to permissions configured for the Public role can be reached without a token. Anything outside the Public role’s permitted set returns 403 FORBIDDEN.
Exchange credentials for a JWT.
POST /auth/loginContent-Type: application/json
{ "email": "user@example.com", "password": "<password>", "mode": "json", "otp": "<optional one-time-password>"}Body fields:
email(required) — the user’s email.password(required) — the user’s password.mode(optional) —json(default) orcookie. Determines where the refresh token is delivered.otp(optional) — the one-time-password from the user’s TFA app, if TFA is enabled on the account.
A successful response:
{ "data": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "M3p7y4...", "expires": 900000 }}expires is the access token’s lifetime in milliseconds. With mode: "cookie", refresh_token is omitted from the body and set as an httpOnly cookie instead — see Refresh-cookie mode below.
The endpoint stalls failed responses for LOGIN_STALL_TIME milliseconds (default 500) before returning, to mitigate timing attacks against the login surface. Successful responses are not stalled.
Refresh
Section titled “Refresh”Trade a refresh token for a new access token.
POST /auth/refreshContent-Type: application/json
{ "refresh_token": "<refresh-token>", "mode": "json"}If mode is omitted, the platform infers it from the request: a body that contains refresh_token defaults to json; a body that does not defaults to cookie and reads the token from the configured refresh-token cookie. You almost always want to set mode explicitly to avoid surprise.
The response shape matches login:
{ "data": { "access_token": "<new-jwt>", "refresh_token": "<rotated-refresh-token>", "expires": 900000 }}Refresh tokens rotate on every refresh. The token returned by the call is a new one, and the previously-issued one is invalidated. Holding onto the old refresh token after a refresh is a logout. This is intentional: token theft becomes detectable when the legitimate client and the attacker both try to refresh and the second one fails.
Logout
Section titled “Logout”Invalidate a refresh token.
POST /auth/logoutContent-Type: application/json
{ "refresh_token": "<refresh-token>"}The endpoint accepts the refresh token in either the JSON body or the configured refresh-token cookie, whichever the client used to obtain it. If only the cookie is present, the body can be omitted; if both are present, the body wins. When the cookie was used, the platform also clears it on the response.
Logout removes the refresh token from the platform’s session store; subsequent attempts to use it return INVALID_CREDENTIALS. The associated access token is still valid for the rest of its TTL. There is no server-side revocation list for active access tokens, so a short ACCESS_TOKEN_TTL is the protection there.
Password reset
Section titled “Password reset”Two endpoints, two steps.
Request a reset email
Section titled “Request a reset email”POST /auth/password/requestContent-Type: application/json
{ "email": "user@example.com", "reset_url": "https://app.example.com/reset"}Sends a password-reset email to the user with a link to complete the reset. reset_url is optional; without it, the email link points at <PUBLIC_URL>/admin/reset-password. When supplied, the URL must be on the configured PASSWORD_RESET_URL_ALLOW_LIST, which is a security measure that prevents email-based open-redirect attacks.
The endpoint returns 200 whether or not the email matched a known user. This is intentional: a different response for known and unknown emails would leak whether an account exists.
Complete the reset
Section titled “Complete the reset”POST /auth/password/resetContent-Type: application/json
{ "token": "<token-from-email>", "password": "<new-password>"}The token is the signed token included in the reset link (the ?token= query parameter on the URL the user receives in email). On success, the response is empty with status 200. The user can now log in with the new password.
SSO providers
Section titled “SSO providers”CairnCMS supports four SSO driver shapes alongside the local-password provider: OAuth2, OpenID Connect, LDAP, and SAML. Each configured provider gets its own login subtree under /auth/login/<provider-name>, where <provider-name> is the name from the AUTH_<NAME>_DRIVER environment variable that configures it (AUTH_GOOGLE_DRIVER=openid produces /auth/login/google). The route shape inside that subtree depends on the driver:
- OAuth2 and OpenID Connect.
GET /auth/login/<provider>starts the flow by redirecting the browser to the identity provider’s authorization URL. The IdP redirects back to/auth/login/<provider>/callback, accepted as bothGETandPOSTto handle implementations that prefer either, at which point CairnCMS exchanges the code for tokens and finishes the login. Browser flow; not suitable for direct programmatic use. - SAML.
GET /auth/login/<provider>redirects to the IdP’s SSO URL with a SAML request. The IdP posts the signed assertion back toPOST /auth/login/<provider>/acs.GET /auth/login/<provider>/metadatareturns the service-provider metadata XML for IdP configuration.POST /auth/login/<provider>/logoutinitiates SAML single-logout when supported. - LDAP.
POST /auth/login/<provider>accepts a JSON body with the user’s credentials, the same shape as the local-password login. Suitable for direct programmatic use because there is no IdP redirect step.
The login flow that ends a successful SSO authentication produces the same access_token / refresh_token / expires response as /auth/login. The mode: "cookie" option applies the same way for browser-flow drivers; the refresh-token cookie is set on the final redirect response when configured.
The configured providers are listed on the API surface itself:
GET /auth{ "data": [ { "name": "google", "driver": "openid", "icon": "google" }, { "name": "ad", "driver": "ldap", "icon": "active-directory" } ], "disableDefault": false}disableDefault reflects AUTH_DISABLE_DEFAULT; when true, the local-password /auth/login route is not registered and only the per-provider routes are available. The admin app uses this list to render the provider buttons on the login screen.
Two-factor authentication
Section titled “Two-factor authentication”TFA is per-user and time-based (TOTP). Three endpoints under /users/me, all requiring a valid access token:
POST /users/me/tfa/generateContent-Type: application/json
{ "password": "<current-password>" }Returns { "data": { "secret": "...", "otpauth_url": "otpauth://totp/..." } }. The user scans the URL into an authenticator app to register the secret.
POST /users/me/tfa/enableContent-Type: application/json
{ "secret": "<secret-from-generate>", "otp": "<code-from-app>" }Activates TFA after verifying the code. From this point, the user’s logins must include otp in the request body.
POST /users/me/tfa/disableContent-Type: application/json
{ "otp": "<code-from-app>" }Disables TFA after verifying a current code.
A role can require TFA across all of its users by setting enforce_tfa: true. The flag is policy carried on the role record; the API does not return a special “TFA-required” state on login, and a user whose role has enforce_tfa: true but who has not yet enabled TFA still receives a normal access token. The admin app’s router uses the flag to redirect those users into a setup screen, and the TFA setup endpoints themselves are the only routes that get special server-side handling for this case. If you build your own client, treat the role flag as guidance for your own UX. The server is not gating arbitrary requests behind TFA enrollment.
Static tokens
Section titled “Static tokens”Static tokens live on the user record. To issue one, set directus_users.token to a sufficiently random string:
PATCH /users/<user-id>Content-Type: application/jsonAuthorization: Bearer <admin-token>
{ "token": "<long-random-string>" }The token is then valid until cleared ({ "token": null }) or the user’s status is set to anything other than active.
When to use static tokens:
- Service accounts — automation or integrations that hit the API on a schedule and do not interactively log in.
- CI/CD steps — scripts that promote schema or config between environments and need a stable credential.
Two security considerations:
- Static tokens are stored plain text on the user row. Anyone with read access to that row sees the token. The default permission setup hides the column from non-admin reads, but treat the database row itself as sensitive.
- Static tokens never expire. Rotate them periodically and revoke any that may have leaked. Prefer creating a dedicated user per integration so revoking one credential does not require shutting off a shared account.
Where possible, prefer the JWT/refresh-token flow even for non-interactive callers — ACCESS_TOKEN_TTL and refresh-token rotation are the better protections against token theft. Reach for static tokens when something specific to the integration (a service that cannot manage refresh state, a third-party tool that takes a single token) makes the JWT flow impractical.
Refresh-cookie mode
Section titled “Refresh-cookie mode”For interactive web clients, mode: "cookie" is often the right choice. The refresh token is set as an httpOnly cookie that the browser sends back automatically, which:
- Removes the refresh token from JavaScript-readable storage. An XSS exploit that can read
localStoragecannot read anhttpOnlycookie. - Makes refresh trivially automatic — the browser includes the cookie on
/auth/refreshcalls without any client-side glue.
Cookie behavior is governed by four environment variables (see Configuration for full details):
REFRESH_TOKEN_COOKIE_NAME— the cookie’s name. Defaultcairncms_refresh_token.REFRESH_TOKEN_COOKIE_SECURE— set totruein production over HTTPS. Defaultfalse.REFRESH_TOKEN_COOKIE_SAME_SITE—lax(default),strict, ornone.nonerequiressecure: trueand is the right choice for cross-domain SSO setups.REFRESH_TOKEN_COOKIE_DOMAIN— domain to scope the cookie to.
The cookie carries the httpOnly flag in all configurations. For setups where the frontend and the API live on the same origin, the defaults work without further configuration. For cross-origin setups, SameSite=None plus Secure=true plus a CORS-allowed origin is the standard combination.
Auth-specific errors
Section titled “Auth-specific errors”In addition to the global error codes in Introduction, authentication endpoints can return:
| Code | HTTP status | Meaning |
|---|---|---|
INVALID_CREDENTIALS | 401 | Login failed, refresh token is unknown or invalidated, or no token was provided. |
INVALID_OTP | 401 | TFA code rejected. |
INVALID_IP | 401 | The role’s IP allow list rejected the source IP. |
INVALID_PROVIDER | 401 | Provider name does not match the user’s configured provider. |
TOKEN_EXPIRED | 401 | Access or refresh token aged out. Refresh, or log in again. |
INVALID_TOKEN | 403 | Token is structurally invalid or signed with a different secret. |
USER_SUSPENDED | 401 | The user’s status is not active. |
INVALID_CREDENTIALS is intentionally vague — it covers wrong password, unknown email, and unknown refresh token, so the response does not distinguish between “no such user” and “wrong password” for an attacker probing for valid emails.
GraphQL
Section titled “GraphQL”Every REST authentication endpoint has a GraphQL mutation equivalent on /graphql/system:
mutation { auth_login(email: "user@example.com", password: "<password>", mode: cookie) { access_token refresh_token expires }
auth_refresh(refresh_token: "<token>", mode: json) { access_token refresh_token expires }
auth_logout(refresh_token: "<token>")
auth_password_request(email: "user@example.com", reset_url: "https://app.example.com/reset")
auth_password_reset(token: "<token-from-email>", password: "<new-password>")}The semantics, error codes, and side effects match the REST routes. The mode argument on auth_login and auth_refresh works the same way as on REST: cookie sets the refresh token as an httpOnly cookie on the response, json returns it in the response body. SSO providers and TFA endpoints are not in the GraphQL surface — those flows go through REST.
Where to go next
Section titled “Where to go next”- Auth — the operator-side configuration for SSO providers, TFA enforcement, and password policy.
- Users — managing user records, roles, and the static-token field.
- Filters and queries — the query DSL that authenticated requests use to read and write data.
- Configuration — every auth-related environment variable in one place.