These examples exercise the public authentication lifecycle and operational
endpoints against a local server. Start the application with python scripts/dev.py serve and use http://127.0.0.1:8000 as the base URL.
The generated OpenAPI document at /docs remains the source of truth for all
request and response schemas.
curl --request POST http://127.0.0.1:8000/register/ \
--header "Content-Type: application/json" \
--data '{"username":"alice","password":"replace-this-password","email":"alice@example.com"}'Example response:
{
"id": 1,
"username": "alice",
"role": "user",
"email": "alice@example.com",
"email_verified_at": null
}Email is optional, normalized to lowercase, and unique when supplied. Usernames must also be unique. Treat passwords used in examples as disposable local values, never production credentials.
Email delivery is disabled by default. After configuring SMTP, request a verification message with the same response for known, unknown, and already verified addresses:
curl --request POST http://127.0.0.1:8000/auth/email-verification/request \
--header "Content-Type: application/json" \
--data '{"email":"alice@example.com"}'The email contains a time-limited opaque token. The API never returns that raw token. The frontend submits the token from the link:
curl --request POST http://127.0.0.1:8000/auth/email-verification/confirm \
--header "Content-Type: application/json" \
--data '{"token":"paste-token-from-verification-link"}'Successful confirmation is single use. Expired, consumed, unknown, and
wrong-purpose tokens all receive the same generic 400 response.
Password recovery is available only for active accounts with a verified email,
but the request endpoint always returns the same 202 response. This prevents
clients from discovering which accounts exist:
curl --request POST http://127.0.0.1:8000/auth/password-reset/request \
--header "Content-Type: application/json" \
--data '{"email":"alice@example.com"}'The reset email contains an opaque, time-limited token. Submit it with a new password of at least 12 characters and no more than 72 UTF-8 bytes:
curl --request POST http://127.0.0.1:8000/auth/password-reset/confirm \
--header "Content-Type: application/json" \
--data '{"token":"paste-token-from-reset-link","new_password":"replace-with-a-long-new-password"}'A successful reset consumes all outstanding password-reset tokens and revokes every refresh token for the account. It does not issue a new session. Existing JWT access tokens are stateless and remain valid until their configured expiry.
The login endpoint follows the OAuth2 password form convention, so its body is form encoded rather than JSON:
curl --request POST http://127.0.0.1:8000/login/ \
--header "Content-Type: application/x-www-form-urlencoded" \
--header "X-Device-Name: Work laptop" \
--data-urlencode "username=alice" \
--data-urlencode "password=replace-this-password"Example response:
{
"access_token": "eyJ...",
"refresh_token": "opaque-random-value",
"token_type": "bearer"
}Set the returned values in your shell for the next examples:
ACCESS_TOKEN="paste-access-token"
REFRESH_TOKEN="paste-refresh-token"Access tokens are JWTs and are short lived. Refresh tokens are opaque secrets; the database stores only their hashes. Each login creates a separate device session, and refresh-token rotation remains inside that session family.
MFA is disabled by default. After the operator configures MFA_ENABLED=true
and a dedicated MFA_ENCRYPTION_KEY, an authenticated user begins enrollment:
curl --request POST http://127.0.0.1:8000/auth/mfa/totp/enroll \
--header "Authorization: Bearer ${ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"password":"replace-this-password"}'Scan the returned provisioning_uri, then confirm with the current six-digit
code. The response displays recovery codes exactly once; store them securely.
curl --request POST http://127.0.0.1:8000/auth/mfa/totp/confirm \
--header "Authorization: Bearer ${ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"code":"123456"}'Subsequent password login returns mfa_required and a short-lived
challenge_token, not access or refresh tokens. Complete it with either a new
TOTP code or one unused recovery code:
curl --request POST http://127.0.0.1:8000/auth/mfa/challenge/verify \
--header "Content-Type: application/json" \
--data '{"challenge_token":"paste-opaque-challenge","code":"123456"}'Use GET /auth/mfa/status, POST /auth/mfa/recovery-codes/regenerate, and
POST /auth/mfa/disable for lifecycle management. Regeneration and disable
require the current password plus a factor and revoke refresh sessions. A
recovery code is single use. A refreshed access token does not retain recent
MFA status. TOTP is not phishing resistant; prefer WebAuthn/passkeys when that
property is required.
After registering the exact callback URI with a provider and enabling OIDC, open the authorization endpoint in a browser:
curl --include http://127.0.0.1:8000/auth/oidc/authorizeThe API returns a 303 redirect to the configured provider and sets a
short-lived HttpOnly browser-binding cookie. The authorization request uses a
transaction-specific state, nonce, and PKCE S256 challenge. The provider
redirects the same browser to /auth/oidc/callback; the API validates and
consumes the transaction before returning local access and refresh tokens.
To link a provider identity to an existing account, begin from a recently authenticated access token:
curl --request POST --include \
http://127.0.0.1:8000/auth/oidc/link/authorize \
--header "Authorization: Bearer ${ACCESS_TOKEN}"A refresh-issued access token cannot begin linking. The callback binds the provider's immutable issuer and subject to the authenticated user. A matching email never silently links an existing account. List or unlink identities with:
curl http://127.0.0.1:8000/auth/oidc/identities \
--header "Authorization: Bearer ${ACCESS_TOKEN}"
curl --request DELETE \
http://127.0.0.1:8000/auth/oidc/identities/1 \
--header "Authorization: Bearer ${ACCESS_TOKEN}"Link and unlink operations revoke refresh sessions. The last sign-in method of an OIDC-only account cannot be removed. If local MFA is enabled, a successful OIDC callback still returns an MFA challenge rather than bypassing the second factor.
curl http://127.0.0.1:8000/auth/me \
--header "Authorization: Bearer ${ACCESS_TOKEN}"Example response:
{
"id": 1,
"username": "alice",
"role": "user"
}Missing, malformed, expired, or otherwise invalid access tokens return 401.
curl --request POST http://127.0.0.1:8000/auth/refresh \
--header "Content-Type: application/json" \
--data "{\"refresh_token\":\"${REFRESH_TOKEN}\"}"The response has the same shape as login. Replace both local token variables
with the new values. A successful rotation revokes the submitted refresh token,
so replaying it returns 401.
curl --request POST http://127.0.0.1:8000/auth/logout \
--header "Content-Type: application/json" \
--data "{\"refresh_token\":\"${REFRESH_TOKEN}\"}"Logout revokes the refresh token. Existing access tokens remain valid until their configured expiration; clients should discard both tokens locally. The server revokes the submitted token's complete rotation family.
List active refresh-token families for the authenticated user:
curl http://127.0.0.1:8000/auth/sessions \
--header "Authorization: Bearer ${ACCESS_TOKEN}"The response contains a server-generated session ID, bounded device label, and created/last-used/expiry timestamps. It never contains raw tokens, token hashes, or IP addresses. Revoke one family idempotently:
SESSION_ID="paste-session-id"
curl --request DELETE "http://127.0.0.1:8000/auth/sessions/${SESSION_ID}" \
--header "Authorization: Bearer ${ACCESS_TOKEN}"Revoke every refresh-token session:
curl --request DELETE http://127.0.0.1:8000/auth/sessions \
--header "Authorization: Bearer ${ACCESS_TOKEN}"Replaying a refresh token already consumed by rotation revokes the live token in that family. Session revocation does not immediately invalidate stateless JWT access tokens; they remain valid until their configured expiry.
Admin routes require an access token whose current database user has the
admin role. Registration never grants this role and the API does not provide
a public self-promotion path.
ADMIN_ACCESS_TOKEN="paste-admin-access-token"
curl http://127.0.0.1:8000/admin/users \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}"To change an existing user's role as an admin:
curl --request PATCH http://127.0.0.1:8000/admin/users/1/role \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"role":"admin"}'To disable an account and atomically revoke its refresh-token sessions:
curl --request PATCH http://127.0.0.1:8000/admin/users/1/status \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"is_active":false}'Set is_active to true to allow new authentication again. Re-enabling an
account does not restore revoked sessions; the user must sign in again.
Non-admin users receive 403; an unknown user ID returns 404.
curl --fail http://127.0.0.1:8000/health/live
curl --fail http://127.0.0.1:8000/health/ready
curl --fail http://127.0.0.1:8000/metricsLiveness confirms that the process can respond. Readiness additionally checks
database connectivity. Restrict /metrics to trusted monitoring networks in
production.
curl --include http://127.0.0.1:8000/health/live \
--header "X-Request-ID: docs-example-001"The response includes the validated X-Request-ID, and the same value appears
in the structured request log. Invalid IDs are replaced rather than trusted.
$baseUrl = "http://127.0.0.1:8000"
Invoke-RestMethod -Method Post -Uri "$baseUrl/register/" `
-ContentType "application/json" `
-Body '{"username":"alice","password":"replace-this-password"}'
$tokens = Invoke-RestMethod -Method Post -Uri "$baseUrl/login/" `
-ContentType "application/x-www-form-urlencoded" `
-Body @{ username = "alice"; password = "replace-this-password" }
$headers = @{ Authorization = "Bearer $($tokens.access_token)" }
Invoke-RestMethod -Uri "$baseUrl/auth/me" -Headers $headers
$rotated = Invoke-RestMethod -Method Post -Uri "$baseUrl/auth/refresh" `
-ContentType "application/json" `
-Body (@{ refresh_token = $tokens.refresh_token } | ConvertTo-Json)
Invoke-RestMethod -Method Post -Uri "$baseUrl/auth/password-reset/request" `
-ContentType "application/json" `
-Body '{"email":"alice@example.com"}'Expected client errors use a JSON detail field and include X-Request-ID:
{
"detail": "Invalid username or password"
}Unexpected errors return a generic 500 response without leaking internal
exception details. Use the correlation ID to locate the matching structured
log entry.