Skip to content

Powered by Grav + Helios

Authentication

Authentication

Endpoints for JWT login/logout, password reset, two-factor verification, first-run setup, the password policy, invitation accept, login captcha, SSO login, and retrieving the current session. Everything under /auth/ is public (no auth required). GET /me requires an authenticated session.

Endpoints for JWT login/logout, password reset, two-factor verification, first-run setup, the password policy, invitation accept, login captcha, SSO login, and retrieving the current session. Everything under /auth/ is public (no auth required). GET /me requires an authenticated session.

Get Token

POST /auth/token
Log in with a username and password and receive a JWT access token and refresh token, plus a `user` object with the account profile, resolved permissions and demo-mode state (the same data `GET /me` returns). When the account has 2FA enabled, the response is a 2FA challenge instead of tokens (see below); finish the login with `POST /auth/2fa/verify`. The account must be a super admin or hold `admin.login` or `api.access`, and must not be disabled. This is a public endpoint that does not require prior authentication. Rate limited per username through the Login plugin.

Parameters

Name Type Description
username required string User account username
password required string User account password
captcha_token optional string A solved captcha. Required only when the site's login captcha covers the login flow (see `GET /auth/captcha`). For the built-in `cap` provider this is the token from `POST /auth/captcha/redeem`; for Turnstile or reCAPTCHA it is the widget's response token.
JSON
{"username": "admin", "password": "password"}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "admin", "fullname": "Site Admin", "email": "[email protected]", "avatar_url": null, "super_admin": true, "access": {"api": {"super": true}, "site": {"login": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 Tokens generated, or a 2FA challenge when the account has 2FA enabled.
401 Invalid credentials
403 The account has no API or admin login access, or is disabled.
422 Missing username or password, or the captcha was missing or could not be verified.
429 Too many login attempts for this username.

When the account has 2FA enabled, the password is checked but no tokens are issued. The response is still 200, with a short-lived challenge token (valid for 5 minutes) to send to POST /auth/2fa/verify along with the current code:

JSON
{"data": {"requires_2fa": true, "challenge_token": "eyJ...", "expires_in": 300, "token_type": "Challenge"}}

Refresh Token

POST /auth/refresh
Refresh an expired access token using a valid refresh token. The refresh token you send is revoked (token rotation), and the response is a new token pair with the same `user` object `POST /auth/token` returns. This is a public endpoint that does not require prior authentication.

Parameters

Name Type Description
refresh_token required string A valid refresh token obtained from the token endpoint
JSON
{"refresh_token": "eyJ..."}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "admin", "fullname": "Site Admin", "email": "[email protected]", "avatar_url": null, "super_admin": true, "access": {"api": {"super": true}, "site": {"login": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 New tokens generated
401 Invalid or expired refresh token
403 The account is disabled.
422 Missing refresh_token field.

Revoke Token

POST /auth/revoke
Revoke a refresh token (explicit logout). If the request also carries an access token (`X-API-Token` or `Authorization: Bearer`), that access token is revoked too, so the current session ends immediately. When the refresh token is still valid, its user is recorded for the `onApiUserLogout` event. Always returns 204, even if the token was already invalid, so revoke is idempotent.

Parameters

Name Type Description
refresh_token required string The refresh token to invalidate (body field).
JSON
{"refresh_token": "eyJ..."}

Response Codes

204 Token revoked (or already invalid).
422 Missing refresh_token field.

Verify 2FA

POST /auth/2fa/verify
Exchange a 2FA challenge token plus a TOTP code for a full token pair. The challenge token comes from `/auth/token` (or an SSO exchange) when it responds with `requires_2fa: true`. The challenge token is single-use and expires in 5 minutes. The response carries the same `user` object as `/auth/token`. On success, fires `onApiUserLogin` with `method: 2fa`. Shares the login rate limiter with `/auth/token`.

Parameters

Name Type Description
challenge_token required string The short-lived challenge token returned from `/auth/token`.
code required string The 6-digit TOTP code from the user's authenticator app.
JSON
{"challenge_token": "eyJ...", "code": "123456"}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "admin", "fullname": "Site Admin", "email": "[email protected]", "avatar_url": null, "super_admin": true, "access": {"api": {"super": true}, "site": {"login": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 Verification succeeded; token pair issued.
401 Invalid/expired challenge token, or invalid 2FA code.
403 Account disabled, or 2FA support unavailable on the server.
422 Missing challenge_token or code.
429 Too many failed attempts; rate limited.

Forgot Password

POST /auth/forgot-password
Request a password reset email. Always returns a neutral success message regardless of whether the email matches an account — prevents account enumeration. Rate-limited per-user via the Login plugin's `pw_resets` bucket. Requires the Email and Login plugins configured. The reset link points at the admin's own `/reset` route and is valid for 24 hours. When the site's login captcha covers the forgotten-password flow, a solved `captcha_token` is required.

Parameters

Name Type Description
email required string Email address of the account to reset.
admin_base_url optional string Origin + base path of the calling Admin2 client (e.g. `https://my-site.example/admin`). Used to construct the reset link in the email. Must be an `http`/`https` URL whose origin is the site's own or is listed in the API's `cors.origins`; otherwise it is ignored. Falls back to the `Referer` / `Origin` headers (same origin check), then Grav's own root URL.
captcha_token optional string A solved captcha. Required only when the site's login captcha covers this flow (see `GET /auth/captcha`).
JSON
{"email": "[email protected]", "admin_base_url": "https://my-site.example/admin"}
JSON
{"data": {"message": "If an account exists for that email, a reset link has been sent."}}

Response Codes

200 Request accepted (neutral response regardless of match).
422 Missing email field, or the captcha was missing or could not be verified.
429 Rate limit exceeded for this user.

Reset Password

POST /auth/reset-password
Complete a password reset using the token from the reset email. All link failures return the same vague error message to prevent token probing from distinguishing bad user / wrong token / expired token. Once the link checks out, the new password must meet the site's password policy (`system.pwd_regex`, or at least 8 characters when none is set; see `GET /auth/password-policy`); a policy failure names the `password` field and leaves the link usable for another try. On success, every access and refresh token issued to the account before the reset stops working. Rate-limited per-username. Fires `onApiPasswordReset` on success.

Parameters

Name Type Description
username required string Username of the account being reset.
token required string The reset token from the email link.
password required string The new password. Must meet the site's password policy.
JSON
{"username": "admin", "token": "abc123...", "password": "N3w-Pa$$w0rd"}
JSON
{"data": {"message": "Password reset successfully."}}

Response Codes

200 Password updated.
422 Missing required fields, invalid/expired reset link, or the new password does not meet the password policy.
429 Too many attempts; rate limited.

Setup Status

GET /auth/setup
Check whether the instance requires first-run setup. Returns `setup_required: true` only when `user/accounts/` is empty. Also returns `password_policy` (the same object as `GET /auth/password-policy`) so the setup screen can validate the password without a second call. Admin2 polls this on load to decide between showing the setup wizard or the login screen. Public (no auth required).
JSON
{"data": {"setup_required": true, "password_policy": {"regex": "", "min_length": 8, "rules": [{"id": "length", "label": "At least 8 characters", "pattern": ".{8,}"}]}}}

Response Codes

200 Status returned successfully.

Create Initial User

POST /auth/setup
One-time first-run setup — creates the initial super-admin account on a fresh Grav 2.0 install. Active only while `user/accounts/` is empty; 409 thereafter. Grants `api.super` and `site.login` (not `admin.super`) by default so the account has API authority without implying classic-admin authority. Fires `onApiUserCreated` and `onApiSetupComplete`, then issues a login token pair (with the same `user` object `POST /auth/token` returns) so the client can skip straight to the dashboard. Public (no auth required), rate-limited per IP. When the site's login captcha covers the setup flow, a solved `captcha_token` is required.

Parameters

Name Type Description
username required string Username (3–64 chars). It may not start with a period or contain `..`, and may not contain `\ / ? * : ; { }` or a line break.
password required string Password. Must match `system.pwd_regex`, or be at least 8 characters when no regex is set (see `GET /auth/password-policy`).
email required string A valid email address.
fullname optional string Display name for the account (defaults to the username).
title optional string User title (defaults to "Administrator").
captcha_token optional string A solved captcha. Required only when the site's login captcha covers the setup flow (see `GET /auth/captcha`).
JSON
{"username": "admin", "password": "a-good-secret", "email": "[email protected]", "fullname": "Site Admin"}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "admin", "fullname": "Site Admin", "email": "[email protected]", "avatar_url": null, "super_admin": true, "access": {"api": {"super": true}, "site": {"login": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 Account created; token pair issued.
409 Setup has already been completed.
422 Validation failed (missing fields, username format, invalid email, password does not meet the policy, or the captcha was missing or failed).
429 Too many setup attempts from this IP.

Get Current User

GET /me
Return the authenticated user's profile and resolved permissions. Admin2 calls this on every load to bootstrap the UI with the current identity, access map, demo-mode state, running Grav core version, and active admin plugin version. The profile fields are the same `user` object the login endpoints return.
JSON
{"data": {"username": "admin", "fullname": "Site Admin", "email": "[email protected]", "avatar_url": "/user/accounts/avatars/admin.png", "super_admin": true, "access": {"api": {"access": true, "super": true}, "site": {"login": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}, "grav_version": "2.0.0-beta.1", "admin_version": "3.0.0-beta.1"}}

Response Codes

200 Profile returned.
401 Not authenticated.
403 User lacks `api.access`.

The response includes:

  • access — the fully resolved permission map (inherited + direct), not the raw YAML. Use this instead of re-deriving permissions client-side.
  • demo_mode: whether the account is a demo account (access.api.demo). For a demo account, writable lists the paths it may still write and seconds_until_reset counts down to the next demo reset; a normal account gets writable: [] and seconds_until_reset: null. reset_interval (minutes) is always present.
  • grav_version — value of GRAV_VERSION on the server.
  • admin_version — version of the enabled admin plugin (checks admin2 first, then admin), read from its blueprints.yaml. null if neither is enabled.
  • content_editor — the user's preferred content editor (empty string if unset).

Password Policy

GET /auth/password-policy
Return the site's password policy so setup, password reset, invitation and user forms can check a password as it is typed. `regex` is `system.pwd_regex` unchanged. `rules` is the checklist: taken from `system.pwd_rules` when set, otherwise parsed from the regex (length, digit, lowercase, uppercase, symbol). When no `pwd_regex` is set, the API still requires at least 8 characters, so the response reports `min_length: 8` and a single length rule. The same policy is enforced by setup, invitation accept, password reset, and user create/update. Public (no auth required).
JSON
{"data": {"regex": "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}", "min_length": 8, "rules": [{"id": "length", "label": "At least 8 characters", "pattern": ".{8,}"}, {"id": "digit", "label": "At least one number", "pattern": "\\d"}, {"id": "lowercase", "label": "At least one lowercase letter", "pattern": "[a-z]"}, {"id": "uppercase", "label": "At least one uppercase letter", "pattern": "[A-Z]"}]}}

Response Codes

200 Policy returned.

With no system.pwd_regex set, the response is:

JSON
{"data": {"regex": "", "min_length": 8, "rules": [{"id": "length", "label": "At least 8 characters", "pattern": ".{8,}"}]}}

Validate Invitation

GET /auth/invite/{token}
Look up an invitation so the accept screen can show who it is for. An expired invitation still returns 200, with `valid: false`, `expired: true` and the email only. An unknown token returns 404. Public: the token is the only credential.

Parameters

Name Type Description
token required string The invitation token from the emailed invite link.
JSON
{"data": {"valid": true, "expired": false, "email": "[email protected]", "fullname": "Jane Doe"}}

Response Codes

200 Invitation status returned (including expired invitations).
404 This invitation is invalid.

Accept Invitation

POST /auth/invite/{token}
Create the invited account and log it in. The email address, access and groups come from the invitation and cannot be changed by the request. The password must meet the site's password policy (see `GET /auth/password-policy`). The invitation is consumed once the account is created; an expired one is deleted and answered with 410. The login then runs the same check as `POST /auth/token`: if the invitation's access grants no API or admin login (`api.access`, `admin.login` or super), the account is still created but no tokens are issued and the response is 403. On success the response is a token pair with the `user` object and an `X-Invalidates: users:list` header. Fires `onApiUserCreated` and `onApiInvitationAccepted`. Public: the token is the only credential.

Parameters

Name Type Description
token required string The invitation token from the emailed invite link (path parameter).
username required string Username (3–64 chars). It may not start with a period or contain `..`, and may not contain `\ / ? * : ; { }` or a line break.
password required string Password. Must match `system.pwd_regex`, or be at least 8 characters when no regex is set.
fullname optional string Display name (defaults to the name on the invitation).
title optional string User title.
JSON
{"username": "jane", "password": "Pa$$w0rd!", "fullname": "Jane Doe"}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "jane", "fullname": "Jane Doe", "email": "[email protected]", "avatar_url": null, "super_admin": false, "access": {"api": {"access": true, "pages": {"read": true}}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 Account created and logged in.
403 Account created, but the invitation grants no API or admin login access, so no tokens were issued.
404 This invitation is invalid.
409 An account with that username already exists.
410 This invitation has expired.
422 Missing fields, invalid username format, or the password does not meet the policy.

Captcha Config

GET /auth/captcha
Report which captcha, if any, guards the public login, forgotten-password and setup endpoints, so a client can render the right challenge before submitting. `flows` says which of those flows are gated, and `token_field` names the body field the solved token goes in (`captcha_token`). For the built-in `cap` provider, `endpoint` is the base URL of the challenge/redeem pair; for `turnstile` and `recaptcha` (provided by the Form plugin), `site_key` is the public widget key, and reCAPTCHA also reports `version`. Never returns a secret. Public (no auth required).
JSON
{"data": {"enabled": true, "provider": "cap", "mode": "invisible", "token_field": "captcha_token", "flows": {"login": true, "forgot_password": true, "setup": false}, "endpoint": "https://example.com/api/v1/auth/captcha/"}}

Response Codes

200 Captcha configuration returned. When captcha is off, `enabled` is false and `provider` is null.

Captcha Challenge

POST /auth/captcha/challenge
Issue a cap.js proof-of-work challenge for the client to solve. Only available while captcha is enabled and `cap` is the active provider; 404 otherwise. The response is the cap.js wire format at the top level, not this API's `data` envelope, and is sent with `Cache-Control: no-store`. Public (no auth required).
JSON
{"challenge": {"c": 30, "s": 32, "d": 4}, "token": "72d323130ee3541275ca2dc1aa9607b4ca61095a08", "expires": 1787332679923}

Response Codes

200 Challenge issued.
404 Captcha challenges are not available (captcha off, or a provider other than cap).

Captcha Redeem

POST /auth/captcha/redeem
Trade the solved sub-challenges for a single-use token to send as `captcha_token` with the login, forgotten-password or setup request. Only available while captcha is enabled and `cap` is the active provider; 404 otherwise. The response is the cap.js wire format at the top level, not this API's `data` envelope; `success` is false when the solutions do not verify. Public (no auth required).

Parameters

Name Type Description
token required string The challenge token from `POST /auth/captcha/challenge`.
solutions required array One integer nonce per sub-challenge, in order.
JSON
{"token": "72d323130ee3541275ca2dc1aa9607b4ca61095a08", "solutions": [1043, 88, 5121]}
JSON
{"success": true, "token": "e734e7c49c34bb0d:d43a6be90ca08cf9f91171a5320e0a", "expires": 1787333298530}

Response Codes

200 Redemption result (check `success`).
404 Captcha challenges are not available (captcha off, or a provider other than cap).
422 Missing token, or solutions is not an array.

SSO Providers

GET /auth/sso/providers
Return the single sign-on buttons to show on the login screen, collected from every plugin that answers the `onApiLoginProviders` event (for example Login OAuth2). An empty list means password login only. Public (no auth required).
JSON
{"data": {"providers": [{"id": "github", "label": "GitHub", "icon": "fa-github", "plugin": "login-oauth2"}]}}

Response Codes

200 Providers returned (possibly an empty list).

Start SSO Login

GET /auth/sso/{provider}/start
Browser navigation, not an XHR call. Stores the admin's return target in the PHP session, then redirects to the provider's authorization URL built by the plugin that answers `onApiLoginStart` (that plugin keeps its own CSRF state in the same session). When no plugin handles the provider, redirects back to the admin's `/login?sso_error=` instead (default `sso_unavailable`). This route keeps its session cookie so the callback can read the state back. Public (no auth required).

Parameters

Name Type Description
provider required string Provider id from `GET /auth/sso/providers` (path parameter).
returnTo optional string In-app path to land on after login (query parameter). Only a root-relative path is kept; absolute URLs, `//host` and control characters are dropped.

Response Codes

302 Redirect to the provider, or to the admin login screen with an `sso_error` query parameter.

SSO Callback

GET /auth/sso/{provider}/callback
The provider redirects the browser here. The plugin that answers `onApiLoginCallback` validates the response and resolves a Grav user; the same API-access, account-state and 2FA checks as `POST /auth/token` then run. The resulting token pair or 2FA challenge is stored under a single-use code (valid for 120 seconds) and the browser is redirected to `/oauth-callback?code=` (plus `&returnTo=` when one was given), where the admin trades it with `POST /auth/sso/exchange`. On failure the browser goes to `/login?sso_error=` with `sso_failed`, `sso_forbidden` (no API access or account disabled), or a provider-specific code. Public (no auth required).

Parameters

Name Type Description
provider required string Provider id (path parameter).

Response Codes

302 Redirect back into the admin, carrying either a one-time exchange code or an `sso_error`.

SSO Exchange

POST /auth/sso/exchange
Exchange the one-time code from the SSO callback redirect for the stored login result. The body is the same as `POST /auth/token` returns: either a token pair with the `user` object, or a `requires_2fa` challenge to finish with `POST /auth/2fa/verify`. The code is deleted on first use and expires 120 seconds after the callback. Public (no auth required).

Parameters

Name Type Description
code required string Hex code from the `oauth-callback` redirect.
JSON
{"code": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}
JSON
{"data": {"access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "user": {"username": "jane", "fullname": "Jane Doe", "email": "[email protected]", "avatar_url": null, "super_admin": false, "access": {"api": {"access": true}}, "content_editor": "", "demo_mode": {"enabled": false, "writable": [], "reset_interval": 30, "seconds_until_reset": null}}}}

Response Codes

200 Login result: a token pair, or a 2FA challenge.
401 Invalid or expired exchange code (unknown, malformed, already used, or expired).
422 Missing code field.