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.
| Endpoint | Who can call it |
|---|---|
GET /users, GET /users/:id | admin, or users:read |
POST /users, PUT /users/:id, DELETE /users/:id | admin, or users:write |
GET /users/by-username/:username, GET /users/by-email/:email | admin, 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.
| Role | Can do |
|---|---|
admin | Everything, including users, settings, tokens and webhooks. |
editor | Read, write, delete and publish content; full media; Studio access. |
author | Read, write and publish content; read and upload media; Studio access. |
writer | Read and write content; read and upload media; Studio access. No publish, no delete. |
viewer | Read content and media; Studio access. |
api | Nothing 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
Section titled “Permissions”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.
API tokens
Section titled “API tokens”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.
Two rough edges in the current implementation:
GET /tokens/:idandPUT /tokens/:idreturn 501. Listing works (GET /tokens), revoking works (DELETE /tokens/:id), reading or editing a single token does not.- The
createdByfield on a new token is recorded assystemrather than the caller. Do not use it for attribution.
Signing in
Section titled “Signing in”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,userandexpiresAt. - MFA required —
requiresMFA: true, anmfaToken, the configuredmethodsand an expiry. Finish atPOST /auth/mfa/verify. - MFA setup required —
requiresMFASetup: trueand asetupToken, when the instance requires MFA and this user has not configured any. Finish through the setup endpoints, passing the token in anx-mfa-setup-tokenheader.
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:
| Endpoint | Does |
|---|---|
GET /auth/me | The full current user, without the password hash. |
POST /auth/validate | Verifies a token and returns the session. |
POST /auth/refresh | Exchanges a refresh token for a new access token. |
POST /auth/logout | Returns 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.
OAuth as an identity provider
Section titled “OAuth as an identity provider”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 signs 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.
Available scopes are openid, profile, content:read, content:write, content:delete, media:read, media:write and offline_access. They map onto permissions on the way in — content:write grants content:read and content:write, media:write grants media:read, media:upload and media:edit. openid, profile and offline_access grant no permissions of their own.
The device flow the CLI uses
Section titled “The device flow the CLI uses”trokky-cli is a built-in public client, allowed all of the scopes above and the device code and refresh token grants. You do not register it.
- The CLI posts to
/auth/devicewithclient_id: "trokky-cli"and its scopes. - The server returns a
device_code, a shortuser_code, averification_uripointing at/studio/auth/device, averification_uri_completewith the code in the query string,expires_inand a pollinginterval. - The CLI prints the URL and the code, tries to open a browser, and starts polling
/auth/tokenwithgrant_type: urn:ietf:params:oauth:grant-type:device_code. - You sign in to Studio and approve. Studio reads the code with
GET /auth/device/verify?code=…and posts the decision toPOST /auth/device/verify. Both require an authenticated session. - The next poll returns an access token, a refresh token, the granted scope and a lifetime. The CLI writes them to its config file.
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. The CLI stops polling when the device code expires.
The device code is never stored in the clear — the server keeps a hash of it, keyed by the user code.
For a browser application rather than a CLI, register a client with redirect URIs under oauth2.clients and use GET /auth/authorize and POST /auth/authorize with PKCE, exchanging the code at POST /auth/token.
Passkeys
Section titled “Passkeys”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 withPOST /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.
Status codes
Section titled “Status codes”Since 2.0.1:
- 401 — you are not authenticated. The token is missing, malformed, invalid or expired.
- 403 — you are authenticated, but not allowed to do this.
- 400 — the request itself is malformed.
Before 2.0.1 all three came back as 400. A client that treats 400 as “not signed in” misbehaves after the upgrade, and a client that refreshes its token on any failure will now 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" } }Branch on code rather than on the status alone if you need to be precise: some permission failures on the collection endpoints still surface as 400 with an INVALID_INPUT code, and reading the status by itself will tell you the request was malformed when it was not.
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 — an OAuth2 call with oauth2.enabled unset, or GET /tokens/:id.
The full endpoint list is in the HTTP API reference.