Skip to content

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.

CairnCMS recognizes two token shapes at request time:

  • Access tokens (JWT). Signed with SECRET, short-lived, issued by /auth/login and 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 is 15m, configurable via ACCESS_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.

Two ways to send a token:

  • Authorization headerAuthorization: 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_token query 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, and Referer headers; 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/login
Content-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) or cookie. 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.

Trade a refresh token for a new access token.

POST /auth/refresh
Content-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.

Invalidate a refresh token.

POST /auth/logout
Content-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.

Two endpoints, two steps.

POST /auth/password/request
Content-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.

POST /auth/password/reset
Content-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.

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 both GET and POST to 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 to POST /auth/login/<provider>/acs. GET /auth/login/<provider>/metadata returns the service-provider metadata XML for IdP configuration. POST /auth/login/<provider>/logout initiates 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.

TFA is per-user and time-based (TOTP). Three endpoints under /users/me, all requiring a valid access token:

POST /users/me/tfa/generate
Content-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/enable
Content-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/disable
Content-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 live on the user record. To issue one, set directus_users.token to a sufficiently random string:

PATCH /users/<user-id>
Content-Type: application/json
Authorization: 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.

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 localStorage cannot read an httpOnly cookie.
  • Makes refresh trivially automatic — the browser includes the cookie on /auth/refresh calls 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. Default cairncms_refresh_token.
  • REFRESH_TOKEN_COOKIE_SECURE — set to true in production over HTTPS. Default false.
  • REFRESH_TOKEN_COOKIE_SAME_SITElax (default), strict, or none. none requires secure: true and 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.

In addition to the global error codes in Introduction, authentication endpoints can return:

CodeHTTP statusMeaning
INVALID_CREDENTIALS401Login failed, refresh token is unknown or invalidated, or no token was provided.
INVALID_OTP401TFA code rejected.
INVALID_IP401The role’s IP allow list rejected the source IP.
INVALID_PROVIDER401Provider name does not match the user’s configured provider.
TOKEN_EXPIRED401Access or refresh token aged out. Refresh, or log in again.
INVALID_TOKEN403Token is structurally invalid or signed with a different secret.
USER_SUSPENDED401The 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.

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.

  • 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.