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.

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.

Two rough edges in the current implementation:

  • GET /tokens/:id and PUT /tokens/:id return 501. Listing works (GET /tokens), revoking works (DELETE /tokens/:id), reading or editing a single token does not.
  • The createdBy field on a new token is recorded as system rather than the caller. Do not use it for attribution.
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:

  • Successtoken, refreshToken, user and expiresAt.
  • MFA requiredrequiresMFA: true, an mfaToken, the configured methods and an expiry. Finish at POST /auth/mfa/verify.
  • MFA setup requiredrequiresMFASetup: 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 full current user, without the password hash.
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 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.

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.

  1. The CLI posts to /auth/device with client_id: "trokky-cli" and its scopes.
  2. The server returns a device_code, a short user_code, a verification_uri pointing at /studio/auth/device, a verification_uri_complete with the code in the query string, expires_in and a polling interval.
  3. The CLI 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 and approve. Studio reads the code with GET /auth/device/verify?code=… and posts the decision to POST /auth/device/verify. Both require an authenticated session.
  5. 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 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.

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.