Skip to content

Users and authentication

Trokky authenticates two kinds of caller: users, who sign in and get a JWT, and API tokens, which are long random strings issued once and carried in the same Authorization: Bearer header. Both resolve to a session with a role and a permission list, and every protected endpoint checks that list.

A user has a username, an email, a password hash, a first and last name, a role, an explicit permission list, and an active flag. Optional pieces hang off the same record: linked OAuth accounts, MFA configuration, registered passkeys, preferences.

The first user comes from configuration, not from an endpoint:

security: {
adminUser: {
username: 'admin',
email: 'admin@example.com',
password: process.env.TROKKY_ADMIN_PASSWORD!,
firstName: 'Site',
lastName: 'Admin',
},
}

That account is created on startup if it does not exist. Everything after it goes through /users.

EndpointWho can call it
GET /users, GET /users/:idadmin, or users:read
POST /users, PUT /users/:id, DELETE /users/:idadmin, or users:write
GET /users/by-username/:username, GET /users/by-email/:emailadmin, or users:write

The two lookup-by-field endpoints require write access even though they only read. That is how the checks are wired, not a considered decision — do not build a directory feature on them.

A role is a named set of default permissions.

RoleCan do
adminEverything, including users, settings, tokens and webhooks.
editorRead, write, delete and publish content; full media; Studio access.
authorRead, write and publish content; read and upload media; Studio access.
writerRead and write content; read and upload media; Studio access. No publish, no delete.
viewerRead content and media; Studio access.
apiNothing by default. API tokens carry their own permissions.

writer exists for the review workflow: a writer can prepare a document but cannot make it public. Publishing is enforced on the transition, not on the document — changing _status to or from published is checked against the publish permission separately from the write that carries it.

Permissions are resource:action strings. The fixed set:

content:read, content:write, content:delete, content:publish, content:*, media:read, media:upload, media:edit, media:delete, users:read, users:write, users:delete, users:invite, settings:read, settings:write, studio:access, tokens:read, tokens:write, tokens:delete, webhooks:read, webhooks:write, webhooks:delete, webhooks:test.

On top of that, any <schema>:<action> string works. A caller reading /collections/post passes if any of these is true:

  • the role is admin
  • the permissions include post:read
  • the permissions include post:*
  • the permissions include content:*
  • the permissions include content:read

So content:read grants read on every collection, and post:read grants read on one. Use per-schema permissions for tokens that should only see part of the site.

Media and token endpoints check their own permissions: media:read, media:upload, media:edit and media:delete, and tokens:read, tokens:write and tokens:delete. There, a user is judged on their stored permissions plus their role’s defaults, so an account created before its role gained a permission still has it. An API token is judged on its own permissions only. Before 3.5.1 these endpoints only checked that the caller was signed in.

Terminal window
curl -X POST https://cms.example.com/api/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "astro-frontend",
"permissions": ["content:read", "media:read"],
"expiresAt": "2027-01-01T00:00:00.000Z"
}'

The response carries the plaintext token once, alongside the stored record. It is never retrievable again — only a SHA-256 hash is kept. Lose it and you issue a new one.

expiresAt is optional; leave it out and the token does not expire. Set one anyway for any token you hand to a third party or an agent. An expired token fails authentication, and a past or malformed expiresAt is refused with a 400. Before 3.5.1 the date was stored but never checked.

Creating a token needs tokens:write, which only admins have by default, and a token can never hold a permission its creator lacks. A token that mints tokens is held tighter still: it cannot grant any tokens:* permission, and what it creates must expire no later than it does. That way a leaked token cannot replicate itself past its own expiry or revocation.

createdBy records who created the token. It is attribution only. A request made with a token acts as the token (api-token:<id> in audit logs and document metadata), never as the person who created it, so a leaked read-only token cannot reach its creator’s account.

GET /tokens/:id and PUT /tokens/:id return 501. Listing works (GET /tokens) and revoking works (DELETE /tokens/:id); reading or editing a single token does not.

Terminal window
curl -X POST https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username": "admin", "password": "…"}'

Three outcomes are possible, all with a 200:

  • Success — token, refreshToken, user and expiresAt.
  • MFA required — requiresMFA: true, an mfaToken, the configured methods and an expiry. Finish at POST /auth/mfa/verify.
  • MFA setup required — requiresMFASetup: true and a setupToken, when the instance requires MFA and this user has not configured any. Finish through the setup endpoints, passing the token in an x-mfa-setup-token header.

The body also accepts rememberMe, a deviceId for trusted-device checks, and captchaToken where a CAPTCHA provider is configured.

The rest of the session endpoints:

EndpointDoes
GET /auth/meThe current user, without the password hash or second-factor secrets.
POST /auth/validateVerifies a token and returns the session.
POST /auth/refreshExchanges a refresh token for a new access token.
POST /auth/logoutReturns success and nothing else.

POST /auth/logout does not invalidate anything server-side. The client is expected to discard the token, and the token stays valid until it expires. If you need a token dead now, that is what token lifetimes are for — set security.tokens.accessTokenTtl short enough to matter.

Password handling is POST /auth/request-reset, POST /auth/verify-reset-token, POST /auth/reset-password, and POST /auth/change-password for an authenticated user.

Google is the one implemented provider. Configure it and Studio offers Google sign-in:

oauth: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'https://cms.example.com/studio/auth/google',
},
}

POST /auth/oauth/google/init starts the flow and POST /auth/oauth/google/callback completes it. Both accept mode: 'link', which attaches the Google account to the signed-in user instead of signing someone in; that mode requires an existing session. DELETE /auth/oauth/google/unlink detaches it, and GET /auth/oauth/status reports whether the provider is configured at all.

OAuth2, where Trokky is the authorization server

Section titled “OAuth2, where Trokky is the authorization server”

Separately from the above, Trokky implements RFC 8628 device authorization and the authorization code grant with PKCE. This is how the CLI and the MCP server sign in, and how you would let another application sign users in against your CMS.

oauth2: {
enabled: true,
issuer: 'https://cms.example.com',
}

With enabled unset, the OAuth2 endpoints return 501, and trokky login fails at the first request. Clients can discover the endpoints and scopes at GET /.well-known/oauth-authorization-server under the API path (https://cms.example.com/api/.well-known/oauth-authorization-server).

ScopeGrants
content:readcontent:read
content:writecontent:read, content:write
content:publishcontent:read, content:publish
content:deletecontent:read, content:delete
media:readmedia:read
media:writemedia:read, media:upload, media:edit
media:deletemedia:read, media:delete
openid, profile, offline_accessNo permissions. profile lets the token read GET /auth/me; offline_access gets a refresh token.

A token holds its scopes, capped by its user. What an OAuth2 token may do is the permissions its scopes grant, intersected with what the user who approved it may do. It is never treated as an admin, even when an admin approved it, and a user limited to some collections stays limited to them. Before 3.5.2 a token carried its user’s full rights whatever scopes it was granted.

An application never manages the account. An OAuth2 token is refused on every /auth/* route (passwords, passkeys, MFA, linked accounts, approving other sign-ins, /auth/refresh) and on administration: users, API tokens, webhooks and settings changes. The one exception is GET /auth/me, which with the profile scope returns a minimal profile: id, username, email, name and role.

The person approving sees each requested scope with a checkbox. What they leave ticked is what the application gets; they can narrow the request, never widen it. The approval is remembered per client, and a new approval replaces the old one, so a narrowing sticks the next time the application asks.

trokky-cli and trokky-mcp (shown as “Trokky MCP (AI agent)”) are built-in public clients, allowed every scope and the device code and refresh token grants. You do not register them.

  1. The client posts to /auth/device with its client_id and scopes.
  2. The server returns a device_code, a short user_code, a verification_uri pointing at /studio/auth/device (or at oauth2.verificationUri if you mount the Studio elsewhere), a verification_uri_complete with the code in the query string, expires_in and a polling interval.
  3. The client prints the URL and the code, tries to open a browser, and starts polling /auth/token with grant_type: urn:ietf:params:oauth:grant-type:device_code.
  4. You sign in to Studio, check the code, untick anything you do not want to give, and approve. Studio reads the code with GET /auth/device/verify?code=… and posts the decision to POST /auth/device/verify. Both require a Studio session.
  5. The next poll returns an access token, a refresh token when offline_access was granted, the granted scope and a lifetime.

While you have not approved yet, the token endpoint answers authorization_pending. slow_down means poll less often, access_denied means you declined, expired_token means the code aged out. One code takes one decision: a second approve or deny on it is refused.

Device and authorization codes are kept in the data adapter, so the approval and the poll can land on different processes, replicas or Workers isolates. The device code itself is never stored in the clear, only a hash of it.

Access tokens last an hour and refresh tokens 30 days (oauth2.accessTokenTtl and oauth2.refreshTokenTtl, in seconds). Each refresh issues a new refresh token, so a client in regular use stays signed in.

For a browser or server application, register a client with its redirect URIs and use GET /auth/authorize and POST /auth/authorize with PKCE, exchanging the code at POST /auth/token.

oauth2: {
enabled: true,
issuer: 'https://cms.example.com',
clients: [
{
id: 'staff-portal',
name: 'Staff portal',
redirectUris: ['https://staff.example.com/auth/callback'],
allowedScopes: ['openid', 'profile', 'content:read', 'content:write', 'offline_access'],
trusted: true,
},
],
}

allowedScopes defaults to every scope, and grantTypes to authorization_code and refresh_token. An entry with the id of a built-in client replaces it, which is how a site narrows what the CLI may ask for. If you do, list grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'], or the CLI cannot sign in at all, and allow content:publish and media:delete, or trokky restore and trokky clean fail.

trusted: true is for your organisation’s own applications built on Trokky sign-in. A trusted client’s tokens act as the signed-in user, with the user’s own role and permissions whatever scopes they carry, so they pass auth: 'admin' custom routes. They are still refused on account, security and administration routes like every OAuth2 token. Trust is checked each time a token is used, so marking a client trusted upgrades its outstanding tokens too. The built-in trokky-cli and trokky-mcp cannot be made trusted. Default false.

Every approval is a grant, and every token issued under it names it. People see the applications signed in as them in Studio under Preferences > Connected applications (the menu under their name), with what each may do, when it was last used and where it signed in from (the CLI and the MCP server name the machine). Admins see everyone’s under Users & Access > Connected applications.

Revoke ends every token the application holds at once, access tokens included, and forgets the approval, so the application has to ask again. An application can give its own access back with RFC 7009 revocation: POST /auth/revoke with { token, client_id }. trokky logout and the MCP server’s remove_site do.

A grant nobody has used for longer than the refresh token lifetime lapses on its own and disappears from the list.

Tokens issued before 3.5.2 belong to no grant and are refused. Everyone signed in through an application signs in again once after the upgrade.

Passkeys are WebAuthn credentials, used as a passwordless sign-in. They need configuration that matches the domain the Studio is served from:

security: {
passkey: {
enabled: true,
rpId: 'cms.example.com',
rpName: 'Example CMS',
origin: 'https://cms.example.com',
},
}

rpId must be the registrable domain and origin the exact origin the browser sees, scheme included. Get either wrong and registration fails in the browser rather than on the server, which makes the mistake harder to see than it should be.

Registration is POST /auth/passkey/register/options then /verify, both requiring an existing session — you register a passkey while signed in, you do not sign up with one. Signing in is POST /auth/passkey/login/options then /verify, both public. Registered credentials are managed at GET /auth/passkey/credentials, PATCH /auth/passkey/credentials/:credentialId to rename, and DELETE on the same path.

GET /auth/passkey/status reports whether passkeys are configured, so a login page can decide whether to offer the button.

Two second factors are implemented: TOTP (an authenticator app) and email OTP. There is no SMS.

  • TOTP codes are checked against the current time step and one step either side, so a phone whose clock has drifted a little still works.
  • Email codes are six digits and last ten minutes.
  • Enabling a method also issues ten backup codes, eight characters each, stored hashed. Each is usable once, at POST /auth/mfa/verify-backup. Regenerate with POST /auth/mfa/backup-codes/regenerate, which invalidates the previous set.

Setting up a method takes two calls — POST /auth/mfa/setup/totp then /setup/totp/verify, or /setup/email then /setup/email/verify. Both pairs accept either a normal session or an x-mfa-setup-token header, which is what makes it possible to set MFA up during a login that demanded it.

GET /auth/mfa/status reports what a user has configured. POST /auth/mfa/disable removes one method, POST /auth/mfa/disable-all removes all of them.

Trusted devices let a browser skip the second factor for a while. They are listed at GET /auth/mfa/trusted-devices and revoked individually or all at once with DELETE on that path. A device is identified by a fingerprint the client sends as deviceId at login, so revoking is per browser, not per person.

To require MFA across the instance, set mfaRequired in the instance settings (PUT /config/settings). Users without a configured method then get the requiresMFASetup response at login instead of a token.

If someone loses their authenticator, an admin can clear their MFA with POST /admin/users/:userId/mfa/reset. That endpoint checks the role directly and refuses anyone who is not an admin, permissions notwithstanding.

  • 401: you are not authenticated. The token is missing, malformed, invalid or expired, or the credentials are wrong.
  • 403: you are authenticated, but not allowed to do this.
  • 400: the request itself is malformed.

That split holds across the API as of 3.5.2. Before 2.0.1 all three came back as 400, and until 3.5.2 some permission denials still did, while account routes that failed authentication could answer 500. A client that refreshes its token on any failure will refresh on a permission denial and get the same denial back. Refresh on 401 only.

The error body carries a code alongside the status:

{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid or expired authentication token" } }

UNAUTHORIZED, FORBIDDEN and INVALID_INPUT go with 401, 403 and 400.

Other codes you will meet: 404 for a missing document, 429 when a rate limit trips, 501 for an endpoint that exists but is not implemented on this instance, such as an OAuth2 call with oauth2.enabled unset, or GET /tokens/:id.

The full endpoint list is in the HTTP API reference.