Authentication Endpoints
This page documents the email/password credential and session-based authentication endpoints: registration, email verification, login (including the 2FA challenge), logout, and password reset.
These endpoints are the primary surface used by platform/id for local account creation and sign-in. They are the credential layer that runs before the OAuth consent flow.
- Authentication — Bearer token / API key authentication for protected API endpoints.
- Two-Factor Authentication — TOTP setup, backup codes, and 2FA management.
- GraphQL Authentication — using session tokens / API keys with the GraphQL API.
Social / OAuth provider sign-in (Twitch, Discord, GitHub, etc.) is handled separately via NextAuth callbacks and the oauthSignin GraphQL mutation, which provisions a user from an external provider. The credential endpoints below cover only email-platform (password) authentication.
Overview
- Base path: all endpoints are mounted under the
/v1scope. - Credential storage: passwords are hashed with Argon2id (
heimdall-auth); a minimum length of 8 characters is enforced on register and reset. - Sessions: a successful login creates a database
Sessionand returns a session token of the formses_<id>that is valid for 30 days. - 2FA challenge: if the account has 2FA enabled, login does not return a session token. Instead it returns
requiresTwoFactor = trueplus a short-lived temporary token of the form2fa_<id>(valid for 10 minutes), which must be exchanged via the 2FA verify endpoint. - Bot protection: when Cloudflare Turnstile is enabled in config,
register,login,reset-password, andreset-password/completerequire a validturnstileToken. When Turnstile is disabled the field is ignored.
Field conventions: Rust/REST JSON uses
snake_case; GraphQL/TypeScript usescamelCase(auto-converted). The tables below list RESTsnake_casefield names.
Shared response: AuthResponse
login, register/verify, and 2fa/verify all return an AuthResponse:
| Field | Type | Notes |
|---|---|---|
user_id | string | User UUID. |
email | string | Email-platform address (may be empty if none). |
username | string | Canonical username. |
session_token | string? | Session token (ses_…). Omitted when 2FA is required. |
session_id | string? | DB session ID (for WebSocket session targeting). Omitted when 2FA is required. |
expires_at | datetime? | Session expiry. Omitted when 2FA is required. |
provider | string? | Primary login provider slug (e.g. email, twitch, discord). |
requires_2fa | bool | true when a 2FA challenge must be completed. |
temp_token | string? | Temporary 2FA token (2fa_…). Only set when requires_2fa is true. |
preferred_locale | string? | User locale (e.g. en, de). |
Register
POST /v1/auth/register
Content-Type: application/json
Initiates registration by sending a verification email. No account is created yet — a PendingRegistration row is stored and the user must verify their email to complete sign-up.
Auth: none.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Lowercased server-side. |
username | string | yes | 3–30 chars; letters, numbers, and underscores only. Lowercased server-side. |
password | string | yes | Minimum 8 characters. |
turnstile_token | string | conditional | Required when Turnstile is enabled. |
Response — 200 OK:
{
"message": "Verification email sent. Please check your inbox to complete registration.",
"expires_at": "2026-06-28T12:00:00Z"
}
The pending registration (and its verification token) expires after 24 hours.
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Registration disabled (global registration_enabled setting or email platform), username length 3–30 violated, invalid username characters, password shorter than 8, or Turnstile verification failed. |
409 Conflict | Email already registered, username already taken, or a pending registration already exists for that email/username. |
500 Internal Server Error | Failed to send the verification email (the pending row is rolled back). |
Verify Registration
POST /v1/auth/register/verify
Content-Type: application/json
Completes registration after email verification: creates the User, the email PlatformAccount, assigns the default role_user, and immediately creates a session (new users do not yet have 2FA).
Auth: none.
Request body:
| Field | Type | Required |
|---|---|---|
token | string | yes |
Response — 201 Created: an AuthResponse with session_token, session_id, expires_at populated, provider = "email", and requires_2fa = false.
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Invalid or expired verification token. |
409 Conflict | Email or username became taken between request and verification. |
Verify Email (unified)
POST /v1/auth/verify-email
Content-Type: application/json
A unified endpoint that auto-detects the token type and handles three flows:
- Registration — creates a new account (same as
register/verify). - Email link — links an additional email to an existing account.
- Email change — changes the primary email of an existing account.
Auth: none.
Request body:
| Field | Type | Required |
|---|---|---|
token | string | yes |
Response — 200 OK:
| Field | Type | Notes |
|---|---|---|
success | bool | Always true on success. |
message | string | Human-readable result. |
verification_type | enum | One of registration, email_link, email_change. |
user_id | string? | Affected user UUID. |
redirect_url | string | Where the frontend should redirect (e.g. /login?verified=true). |
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Token not found in any table, or expired. |
409 Conflict | Email/username already in use for the detected flow. |
Unlike
register/verify, this endpoint does not return a session — it returns aredirect_urlfor the frontend to follow.
Login
POST /v1/auth/login
Content-Type: application/json
Authenticates with an email or username plus password. The identifier is matched against User.username first, then against the email platform account.
Auth: none.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
identifier | string | yes | Email or username. Lowercased server-side. |
password | string | yes | |
turnstile_token | string | conditional | Required when Turnstile is enabled. |
Response — 200 OK: an AuthResponse.
- No 2FA:
session_token,session_id,expires_atare set,requires_2fa = false,temp_token = null. - 2FA enabled:
session_token/session_id/expires_atarenull,requires_2fa = true, andtemp_token(2fa_…, valid 10 minutes) is returned. The client must then call/v1/auth/2fa/verify.
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Turnstile verification failed. |
401 Unauthorized | Unknown identifier, account has no password authentication, or wrong password (logged as a failed-login audit event). |
Verify 2FA (at login)
POST /v1/auth/2fa/verify
Content-Type: application/json
Completes a login that returned requires_2fa = true. Verifies the TOTP code (or a backup code), consumes the temporary session, and creates a full session preserving the original login provider.
Auth: none (the temp_token is the credential).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
temp_token | string | yes | The 2fa_… token from login. |
code | string | yes | TOTP code from the authenticator app, or a backup code. |
Response — 200 OK: an AuthResponse with a full session (session_token, session_id, expires_at) and requires_2fa = false.
If a backup code is used and the user has 3 or fewer remaining, a "backup codes low" reminder email is sent.
Error cases:
| Status | Cause |
|---|---|
401 Unauthorized | temp_token does not start with 2fa_, is expired/invalid, or the code is wrong (logged as a failed-2FA audit event). |
Check 2FA Status (internal)
POST /v1/auth/2fa/check
Authorization: Bearer <system_api_key>
Content-Type: application/json
Used by OAuth/social providers (NextAuth callbacks) to check whether a freshly authenticated user needs a 2FA challenge, and to obtain a temp token if so. This is an internal endpoint intended for platform/id, not external consumers.
Auth: requires a valid system API key (is_system = true). Returns 403 for non-system keys.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
user_id | string | yes | User to check. |
oauth_provider | string | no | Provider that initiated login (e.g. twitch); stored with the temp token. |
Response — 200 OK:
| Field | Type | Notes |
|---|---|---|
requires_2fa | bool | Whether 2FA is enabled for the user. |
temp_token | string? | 2fa_… token, only present when requires_2fa is true. |
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Invalid user ID format. |
401 Unauthorized | Missing API key. |
403 Forbidden | API key is not a system key. |
Logout
POST /v1/auth/logout
Authorization: Bearer <session_token>
Deletes the session matching the bearer token, invalidates the Redis cache, and logs a logout audit event.
Auth: session token in the Authorization: Bearer header.
Response — 200 OK:
{ "message": "Logged out successfully" }
Error cases:
| Status | Cause |
|---|---|
401 Unauthorized | Missing session token. |
Request Password Reset
POST /v1/auth/reset-password
Content-Type: application/json
Sends a password-reset email if an account exists for the address. Always returns 200 to prevent email enumeration.
Auth: none.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Lowercased server-side. |
turnstile_token | string | conditional | Required when Turnstile is enabled. |
Response — 200 OK:
{ "message": "If an account exists with this email, a reset link has been sent" }
The reset token expires after 1 hour.
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Turnstile verification failed. |
Complete Password Reset
POST /v1/auth/reset-password/complete
Content-Type: application/json
Sets a new password using a reset token. On success, the password is updated, the token is marked used, and all existing sessions for the user are invalidated.
Auth: none (the token is the credential).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Reset token from the email. |
new_password | string | yes | Minimum 8 characters. |
turnstile_token | string | conditional | Required when Turnstile is enabled. |
Response — 200 OK:
{ "message": "Password reset successfully. Please login with your new password." }
Error cases:
| Status | Cause |
|---|---|
400 Bad Request | Invalid or expired reset token, password shorter than 8, or Turnstile verification failed. |
Not Implemented
The following stub endpoints exist in the routing table but return 501 Not Implemented:
POST /v1/auth/signin
POST /v1/auth/validate
Both respond with:
{ "message": "Authentication not implemented yet" }
Use the credential endpoints documented above instead.
GraphQL Equivalents
Most credential operations (register, login, verify-email, password reset) are REST-only; there are no GraphQL mutations for them. The GraphQL API exposes the following auth-related operations for parity where they exist:
verifyTwoFactorLogin mutation
Equivalent to POST /v1/auth/2fa/verify.
mutation VerifyTwoFactorLogin($input: VerifyTwoFactorLoginInput!) {
verifyTwoFactorLogin(input: $input) {
userId
email
username
sessionToken
sessionId
expiresAt
provider
preferredLocale
}
}
Input (VerifyTwoFactorLoginInput):
| Field | Type | Notes |
|---|---|---|
tempToken | String! | The 2fa_… token from login. |
code | String! | TOTP or backup code. |
Returns GqlTwoFactorLoginResponse (a full session; sessionToken, sessionId, and expiresAt are non-nullable here). Requires no authentication — the temp token is the credential.
logout mutation
mutation { logout }
Returns a Boolean. Note: unlike the REST logout (which deletes only the current session), the GraphQL logout deletes all sessions for the authenticated user and invalidates cached permissions. Requires a user session (API keys cannot log out).
oauthSignin mutation
Provisions or updates a user from an external OAuth provider (social login). Requires a system API key and is used by NextAuth backend calls, not by external consumers.
mutation OauthSignin($input: OAuthSignInInput!) {
oauthSignin(input: $input) {
user { id username email avatarUrl platform preferredLocale }
message
}
}
Input (OAuthSignInInput): email (String), name (String!), oauthProvider (String!), oauthProviderId (String!), image (String).