Skip to content

API reference ​

All non-OIDC state-changing endpoints require the X-CSRF-Token header (how). Cookie-based authentication — same-origin only. JSON request + response bodies unless otherwise noted.

Health ​

MethodPathPurpose
GET/api/healthLiveness. Returns {status: "ok"}.
GET/api/readyReadiness. Returns 503 with {status: "not ready", database: "migrating"} until the startup migrations have finished, then verifies the DB connection. The 200 body is exactly status, database, oidcConfigured, registrationEnabled, magicLinkEnabled and passwordRules — what the anonymous sign-in and register pages render, and nothing else. This endpoint is unauthenticated by necessity, so it deliberately no longer reports the running version or the SMTP/cookie posture; see Security → What an unauthenticated caller can learn. Admin /api/admin/users/:id* endpoints return 404 (not 500) for a malformed non-UUID id.
GET/metricsPrometheus metrics (text exposition), including schleuse_build_info{version="…"} — the machine-readable place to read the running version. Bearer-gated by NUXT_METRICS_TOKEN; returns 404 when the token is unset, 401 without a valid Authorization: Bearer <token>. See Configuration and Deployment.

Authentication ​

MethodPathPurpose
POST/api/auth/registerCreate account + send verification email. No session issued. With a CAPTCHA configured, the body must carry captchaToken — missing or rejected → 400 Captcha verification failed, checked before the email-exists answer.
POST/api/auth/loginPassword login. Sets the httpOnly JWT cookie on success — the cookie is the only session transport; no login/2FA/WebAuthn response body includes a token field.
POST/api/auth/logoutClear session cookie. Also fires OIDC back-channel logout to relying parties the user had a session with.
GET/api/auth/verify-email?token=…Verify email (redirects to /login?verified=1).
POST/api/auth/resend-verificationResend the verification email. Same captchaToken rule as /api/auth/register.
POST/api/auth/password/forgotRequest a password-reset link. Same captchaToken rule as /api/auth/register.
POST/api/auth/password/resetSet a new password using the reset token.
POST/api/auth/magic-linkEmail a sign-in link ({ email, rememberMe?, interaction?, captchaToken? }). Always 200 with the same message — nothing is sent for an unknown, unverified or suspended address, or past 3 links per account / 15 min. Same captchaToken rule as /api/auth/register; per-IP limit as for password reset. 404 when NUXT_MAGIC_LINK_ENABLED=false or SMTP is not configured.
GET/api/auth/magic-link/bound{ bound } — whether this browser requested the link given in the X-Magic-Link header. Read-only; the /magic-link page uses it to decide whether to offer its auto-sign-in countdown.
POST/api/auth/magic-link/verifyRedeem a sign-in link ({ token, confirmed? }). Without confirmed: true — the user pressed the button — only the browser that requested the link may redeem it (httpOnly binding cookie set by /api/auth/magic-link); otherwise 409 confirm_required, and the link is not consumed. Answers exactly like /api/auth/login: a session, a pendingToken when the account has TOTP (the emailed code is never offered — the link already was the mailbox), or requiresTwoFactorSetup. Invalid, expired, already used, or minted before a password change / force-logout → 401; suspended → 403; 404 when the feature is off. The emailed URL opens the /magic-link page, which calls this on a button press — a GET never signs anyone in.
POST/api/auth/accept-inviteAccept an admin invite: set the first password ({ token, newPassword }), which activates + verifies the account and signs the user in. Single-use — accepting burns the link (and any other invite outstanding for that account); a replay, or an account that already has a password, → 400. Invalid/expired token → 401, suspended account → 403. Nothing but accepting invalidates the link: it does not expire early because an admin ended the user's sessions.
POST/api/auth/verify-login-codeComplete 2FA login with the emailed 6-digit code. 401 when email wasn't an offered method for this login; 429 (Retry-After) after 5 failed attempts per account / 15 min.
POST/api/auth/verify-totpComplete 2FA login with an authenticator-app code or a one-time backup code. 401 when TOTP wasn't an offered method for this login; per-account rate limit → 429.
POST/api/auth/send-2fa-code(Re)send the emailed login code for a pending 2FA login. 401 when email wasn't offered; 429 after 3 sends per account / 15 min.

Several of these accept an optional interaction uid so an email-driven flow (verification, password reset, invite, magic link) can resume a pending OIDC authorization. That context is honoured only in the browser that started the authorization flow: the uid must match a binding cookie minted when oidc-provider redirected that browser to /login?interaction=…. Requests from any other browser simply take the non-OIDC path — the primary action (verify, reset, accept) still succeeds, the interaction is untouched. /api/oidc/interaction/:uid/finish enforces the same ownership (a signed-in user cannot complete an interaction their browser didn't start — 400) and rejects uids that are not url-safe.

Registration does not issue a session. The user must verify their email (or the NUXT_SMTP_HOST must be empty, in which case they're auto-verified) before they can log in.

Account management ​

Requires authentication.

MethodPathPurpose
GET/api/meCurrent user profile — including hasPassword, passkeyCount, locale, supportedLocales.
PATCH/api/meUpdate first/last/display name or language.
POST/api/me/passwordChange or set password (requires the current password when one exists; on a passkey-only account, setting the initial password requires a passkey assertion { challenge, credential } from /api/me/password-reauth; invalidates other sessions).
POST/api/me/password-reauthStart a passkey re-authentication for any passkey-confirmed account change (remove password, set initial password, delete last passkey): returns WebAuthn options + a purpose-bound challenge scoped to the session user. 400 if no passkeys.
DELETE/api/me/passwordRemove password to go passwordless. Confirm with either { password } or a passkey assertion { challenge, credential } (from /api/me/password-reauth). Requires at least one registered passkey (else 403); invalidates other sessions.
POST/api/me/sessions/revoke-othersSign out of all other devices: bumps token_version (invalidates every other session) and keeps the current one. Returns { message, at } (the moment, for the "last signed out" display).
GET/api/me/sessions/last-revoked-othersWhen the user last used "Sign out other devices" — { at: string | null }, derived from their sessions.revoked_others audit events.
GET/api/me/referrerValidate a "back to the app" link: ?referrer=<clientId>&referrer_uri=<url> → { uri, label } when the URL is same-origin as one of the client's registered redirect URIs (open-redirect guard), else {}.
GET/api/me/activityThe user's own recent account activity (sign-ins, password changes, exports, suspicious-login flags). Self-scoped to the session user; safe fields only (event, status, ip, userAgent, createdAt). Query: limit (default 20, max 100).
GET/api/me/totpTOTP status: { enabled, backupCodesRemaining }.
POST/api/me/totp/startBegin TOTP enrolment: returns { secret, otpauthUri } for the authenticator app. Stored as pending — does not change an existing active credential.
POST/api/me/totp/confirmFinalise enrolment by verifying a { code }; enables TOTP and returns one-time { backupCodes } (shown once). When TOTP is already active, additionally requires currentCode — a code from the current authenticator or an unused backup code — before the secret is replaced.
POST/api/me/totp/backup-codesRegenerate backup codes (requires a current { code }); returns the new set.
DELETE/api/me/totpDisable TOTP (requires a current TOTP or backup { code }; a code already used elsewhere is rejected).

All factor-confirmation checks above (current password, current code, passkey assertion) share one per-user rate limit — 5 failed attempts per 15 minutes across all of them → 429 with Retry-After — and every failed attempt is written to the audit log (reauth.failed). | GET | /api/me/export | Download a GDPR data-portability export (JSON file). Excludes password hashes and OIDC token values. | | DELETE | /api/me | Delete account (requires password; notifies OIDC clients via webhook). |

The export is returned as a file download (Content-Disposition: attachment, Cache-Control: no-store) with top-level keys meta, profile, passwordCredential (metadata only — never the hash), webauthnCredentials, auditLog (the user's own entries), oidcGrants (clientId + scope, no tokens) and roles (role assignments, { roleGroup, role } — the group is the client's roleGroup, or its clientId). Each export is recorded in the audit log.

WebAuthn (passkeys) ​

Passkeys can be added to any verified account as an alternative login method.

MethodPathPurpose
POST/api/webauthn/register/startBegin passkey registration (auth required)
POST/api/webauthn/register/finishComplete passkey registration
POST/api/webauthn/signup/startBegin passwordless sign-up (no account yet). Same captchaToken rule as /api/auth/register
POST/api/webauthn/signup/finishComplete passwordless sign-up; creates the account
POST/api/webauthn/login/startBegin passkey login
POST/api/webauthn/login/finishComplete passkey login; sets auth cookie
GET/api/me/passkeysList the current user's passkeys (no key material)
DELETE/api/me/passkeys/:idRemove a passkey (always audited). Refuses (403) to remove the last passkey when the account has no password; removing the last passkey otherwise requires confirmation — { password } or a passkey assertion (401 with code: confirmation_required when missing)

The two guards above jointly maintain the invariant that an account always keeps at least one authentication factor: a password OR at least one passkey.

OIDC ​

See OIDC provider for the full flow.

MethodPathPurpose
GET/.well-known/openid-configurationDiscovery document
GET/.well-known/jwks.jsonPublic key set
GET/oidc/authorizeAuthorization endpoint
POST/oidc/tokenToken endpoint. grant_type=authorization_code, refresh_token, or client_credentials for a service client (HTTP Basic, no user, no refresh token)
GET/oidc/userinfoUser info endpoint
GET/oidc/jwksJWKS endpoint (alternate path)
GET/oidc/session/endRP-initiated logout (id_token_hint + registered post_logout_redirect_uri); ends the OP session, fires back-channel logout, and clears the first-party app cookie. See OIDC → RP-initiated logout.
POST/oidc/revokeToken revocation (RFC 7009)
POST/oidc/introspectToken introspection (RFC 7662). A service token answers active, client_id and scope — and no sub
POST/api/oidc/interaction/:uid/finishComplete OIDC interaction (internal). Returns 403 loginRoleRequired when the client is role-gated and the user lacks a qualifying role — the user stays signed in, but no code is issued.

/oidc/authorize and /oidc/token accept an optional resource parameter (RFC 8707). When it matches a value in NUXT_OIDC_RESOURCE_AUDIENCE, the access token is issued as a JWT audienced to that resource; otherwise tokens stay opaque and an unconfigured resource is rejected with invalid_target. See OIDC → JWT access tokens.

Admin ​

Requires an authenticated admin session. The first registered user is auto-promoted to admin by default.

MethodPathPurpose
GET/api/admin/statsDashboard counters
GET/api/admin/usersList users with pagination, search, filters
POST/api/admin/usersCreate a user. { email, firstName, lastName?, isAdmin?, locale?, passwordMode: 'set'|'invite', password? }. set → account usable + email-verified immediately; invite → passwordless + unverified, returns { inviteLink } (also emailed when SMTP is on). Duplicate email → 409; weak password in set mode, or a locale not in NUXT_SUPPORTED_LOCALES → 400. Audited admin.user.created.
GET/api/admin/users/:idSingle user, including locale, suspended and supportedLocales (the operator's NUXT_SUPPORTED_LOCALES)
PATCH/api/admin/users/:idUpdate: verify email, toggle admin, edit name, set locale (a tag from NUXT_SUPPORTED_LOCALES, or null to clear it; anything else → 400). Returns the same object as GET. Audited admin.user.updated
GET/api/admin/users/:id/exportDownload a user's GDPR data export (same shape as /api/me/export)
GET/api/admin/users/:id/rolesPer-group roles for the user: { groups: [{ group, roleMode, available, assigned, clientIds }] }
PUT/api/admin/users/:id/rolesReplace the user's roles for one group: body { roleGroup, roles[] }
GET/api/admin/users/:id/metadataApp metadata by namespace: { enabled, namespaces: [{ namespace, clientIds, entries: [{ key, value, updatedAt }] }] }
PATCH/api/admin/users/:id/metadataEdit one namespace: body { namespace, data: { k: "v", stale: null } }. Audited admin.user.metadata_updated
DELETE/api/admin/users/:idDelete user (revokes OIDC tokens, fires webhooks)
GET/api/admin/audit-logsAudit log entries with filters
POST/api/admin/audit-logs/anonymize-orphansMaintenance sweep for pre-anonymisation-deletion leftovers: nulls all PII on rows whose user_id is no longer in users, and nulls reason on rows whose target_user_id is gone while the actor is live (the acting admin's own details stay). { anonymized: N }; idempotent; audited admin.audit.anonymized.
GET/api/admin/configEnv-var catalog + current runtime values (secrets masked, for /admin/configurator)
POST/api/admin/webhooks/:id/testDeliver one signed webhook.test event to that subscription and report what the receiver answered — status, duration, first 500 bytes. Admin-only, CSRF, rate-limited per admin, audited as admin.webhook.test_sent. See Webhooks.
POST/api/admin/force-logout-allInvalidate login sessions in bulk; returns { affected, scope, message, self }. Body { scope?: 'all' | 'non_admins', alsoLogOutSelf?: boolean } — non_admins bumps only non-admin accounts (admins keep working); alsoLogOutSelf (scope all only) signs the acting admin out too.
POST/api/admin/users/:id/reset-two-factorClear a user's second factors so they can re-enrol: removes TOTP always, and passkeys only when the user has a password (else preserved — they're the login); bumps the target's token_version. { clearedTotp, clearedPasskeys }; audited admin.user.two_factor_reset.
POST/api/admin/users/:id/suspendReversibly block an account: sets suspended_at, bumps token_version (kills sessions), and revokes the user's OIDC tokens. New logins are refused. 400 if suspending yourself. { suspended: true }; audited admin.user.suspended.
POST/api/admin/users/:id/unsuspendLift a suspension (suspended_at → null); the user can log in again (re-authenticating fresh). { suspended: false }; audited admin.user.unsuspended.
POST/api/admin/users/:id/force-logoutInvalidate one user's sessions (bumps their token_version). If the target is the acting admin, this device stays signed in (other devices drop). { affected: 1 }; audited admin.user.force_logout.
POST/api/admin/roles/rekeyMove all role assignments from one role-group key to another ({ from, to }) — for when a client's roleGroup changes and its old assignments are orphaned. Conflict-safe; returns { from, to, moved }; audited admin.roles.rekeyed.
GET/api/admin/email/statusSMTP configuration + a live probe: { configured, ok, durationMs, host, port, secure, from, authConfigured, tls, error? }. tls is measured on a real connection, not echoed from config: { mode: 'implicit'|'starttls'|'none', encrypted, authorized, protocol, requireTls, rejectUnauthorized, error? } — authorized is null when the hop isn't encrypted. Never returns NUXT_SMTP_PASS. A failed probe is still 200 — the failure detail is the payload (error.code / command / responseCode / response); configured: false when NUXT_SMTP_HOST is unset.
GET/api/admin/email/previewRender one outbound email with sample data: query template (verification | invite | passwordReset | loginCode | newLogin) and optional locale (must be in NUXT_SUPPORTED_LOCALES). Returns { template, locale, subject, html, text, sources }, where each sources.* is builtin | override | localeOverride. Sends nothing — works with SMTP disabled. 400 on unknown template/locale.
POST/api/admin/email/testSend a sample email through the live SMTP transport: { to, template, locale? }. One attempt, no retry. 200 { ok, messageId, durationMs, to, template, locale }; 409 { code: 'smtp_not_configured' } when SMTP is off; 429 past 10 sends / 10 min per admin; 502 { code: 'smtp_send_failed', smtp: {…} } carrying the SMTP server's own error. Audited admin.email.test_sent.
GET/api/admin/clients/:clientId/secretReveal a confidential client's derived secret: { clientId, secret } (400 for public clients). Audited as admin.client.secret_revealed

Query parameters for /api/admin/users:page, perPage (max 100), search (email/name), emailVerified (true/false), isAdmin (true/false)

Query parameters for /api/admin/audit-logs:page, perPage (max 100), userId (UUID), event (event type), from (ISO date), to (ISO date). Filters are validated — a malformed userId or date is a 400, never a 500.

userId matches the actor (userId) or the subject (targetUserId), so one query answers both "everything about this user" and "everything this admin did". An admin action on someone therefore appears under both ids — one performed it, the other was subject to it. It combines conjunctively with the other filters. Rows written before the target_user_id column existed carry only the actor, so a long-lived instance shows fewer admin actions under a subject's id than actually occurred. admin.user.roles_updated / admin.user.metadata_updated rows written before this release key the target as userId (the acting admin was not recorded); new rows carry the admin as actor and the user as subject.

PUT /api/admin/users/:id/roles replaces the user's roles for the given roleGroup (clients collapsed by roleGroup ?? clientId). roles must be a subset of the group's roles (the union across member clients in NUXT_OIDC_CLIENTS_JSON); an unknown role, an unknown group, or more than one role for a single-mode group returns 400. Logged as admin.user.roles_updated. See OIDC → Per-application roles.

GET /api/admin/email/preview and POST /api/admin/email/test render through the same code path a real send uses, so NUXT_EMAIL_* overrides and their <VAR>_<LOCALE> variants appear exactly as recipients would see them. The sample payload is inert: links carry the placeholder token EXAMPLE-TOKEN-NOT-VALID and the 2FA code is a fixed 123456, so a test email is never a usable credential. Test sends are deliberately excluded from the schleuse_emails_total metric.

App API (server-to-server) ​

Authenticated by a confidential OIDC client's client_id + client_secret via HTTP Basic — no session, no CSRF.

MethodPathPurpose
GET/api/apps/rolesRead the calling client's roles for a user: ?userId= or ?email= → { clientId, roleGroup, userId, roles } (filtered to the roles this client declares). With NUXT_APP_ROLES_REQUIRE_GRANT=true, a user without a grant for the calling client is a 404, indistinguishable from an unknown user
PUT/api/apps/rolesSet the calling client's roles for a user: body { userId or email, roles[] }
GET/api/apps/metadataRead the calling client's metadata namespace for a user: ?userId= or ?email= → { clientId, namespace, userId, data }
PATCH/api/apps/metadataUpsert / delete keys in one call: body { userId or email, data: { k: "v", stale: null } }

The clientId is taken from the authenticated credentials, never the body, so a client can only write its own app-roles. roles must be a subset of the client's env-defined roles (≤1 for roleMode:"single"); only the role store is written (is_admin is never touched). Responses: 200 { clientId, userId, roles }; 400 (invalid role), 401 invalid_client (bad/missing/public credentials), 404 (unknown user), 429 (per-client rate limit). Logged as oidc.client.roles_updated. See OIDC → App-managed roles.

Per-user app metadata (/api/apps/metadata) is a small key→value store, off unless NUXT_APP_METADATA_ENABLED=true — when off both routes return 404, checked before the credentials so a disabled deployment is not an oracle for client secrets. The namespace is the client's metaGroup ?? clientId, taken from the credentials and never the body. In a PATCH body a string upserts the key, null deletes it, and keys not mentioned are left alone; "" stores an empty value. Values are opaque text — Schleuse never parses them. Caps per (user, namespace): 50 keys, 4096 bytes per value, 25 keys per request, keys matching [A-Za-z0-9][A-Za-z0-9._-]{0,127}; exceeding one is a 400 naming the cap. Responses: 200 { clientId, namespace, userId, data }; 400; 401 invalid_client; 404 (disabled, unknown user, or — with NUXT_APP_METADATA_REQUIRE_GRANT — no grant, deliberately indistinguishable); 429. Writes are logged but not audit-logged, unlike roles: metadata may be written on every request. Admin edits via PATCH /api/admin/users/:id/metadata are audited (admin.user.metadata_updated, key names only, never values). See OIDC → Per-user app metadata.

Guards: admins cannot remove their own admin status or delete their own account via the admin API.

POST /api/admin/force-logout-all invalidates every login session in one bulk update (the stateless-JWT revocation point is each user's token_version). The acting admin's current device is re-issued a fresh cookie so it stays signed in; already-issued OIDC tokens are unaffected (they have their own lifecycle via /oidc/revoke). Logged as admin.force_logout_all. See Security → Incident response.

Provisioning API (server-to-server) ​

Bulk user creation / import, for migrating an existing realm onto Schleuse. Authenticated by a bearer token from NUXT_ADMIN_API_TOKENS_JSON — no session, no CSRF. A session cookie never satisfies these routes, even an admin's.

Unset / empty NUXT_ADMIN_API_TOKENS_JSON ⇒ every route below returns 404 (the feature is off; there is no half-open state that merely rejects credentials).

MethodPathPurpose
POST/api/provisioning/usersCreate one user → { status, user: { id, email, legacySub, subPreserved }, inviteLink? }
POST/api/provisioning/users/importBatch (≤ 500), optional dryRun → summary + per-item results
GET/api/provisioning/users/lookupResolve ?legacySub= or ?email= → { id, email, legacySub, subPreserved }

Walkthrough with a Keycloak example: Guide → Migrating users in.

User payload ​

jsonc
{
  "email": "erika@example.com",     // required
  "firstName": "Erika",             // required
  "lastName": "Mustermann",
  "locale": "de",                   // falls back to NUXT_DEFAULT_LOCALE if unsupported
  "emailVerified": true,            // default false
  "createdAt": "2021-03-04T10:00:00Z",  // ISO-8601; preserves the original date
  "suspended": false,               // true ⇒ imported already blocked from login
  "legacySub": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
  "roles": { "my-app": ["editor"] },    // per client or role group
  "hashedPassword": { "value": "$2b$12$…", "algorithm": "bcrypt" },
  "invite": false                   // mutually exclusive with hashedPassword
}

The schema is strict: an unknown or misspelt field is an error, not a silent drop. isAdmin is therefore rejected outright — a provisioning token cannot mint admins. Omit both hashedPassword and invite for a credential-less account.

hashedPassword.value is one self-describing crypt/PHC string; algorithm is optional but must agree with the string's prefix when present. Supported prefixes: $argon2id$/$argon2i$, $2a$/$2b$/$2y$ (bcrypt), and $pbkdf2-sha1|sha256|sha512$ (passlib/passwap encoding). A legacy hash is verified as-is and silently re-hashed to argon2id on the user's next successful login.

subPreserved ​

users.id is the OIDC sub. A UUID-shaped legacySub is used verbatim as the id, so the sub survives the migration (subPreserved: true). Any other value gets a fresh UUID and is recorded for mapping only (subPreserved: false) — downstream apps keyed on the old sub must re-map those users.

Per-item outcomes ​

The batch endpoint always returns 200 when the request itself is well-formed: one bad row never fails the others.

statuscodeMeaning
created—User created (or, under dryRun, would be)
skippedalreadyImportedSame email and same legacySub — an idempotent re-run
conflictemailExistsEmail taken by a user with a different (or no) legacySub
conflictlegacySubExistsThat legacySub belongs to another user
conflictidExistsThe id a UUID legacySub resolves to is taken
conflictduplicateInBatchThe same email / legacySub appears twice in this request
invalidunknownFieldUnrecognised or misspelt field (named in message)
invalidbadHashUnsupported prefix, malformed payload, or a mislabelled algorithm
invalidbadRoleRole not declared by that client, or too many for roleMode:"single"
invalidvalidationMissing/malformed required field
failedwriteFailedA write failed; the partially-created user was rolled back

Email matching for alreadyImported, emailExists and duplicateInBatch is case-insensitive — addresses are canonicalised (trimmed, lowercased) before they are compared or stored, so two spellings of one address are one identity.

POST /api/provisioning/users (single) maps the same outcomes onto status codes instead: 200 created/skipped, 400 invalid, 409 conflict.

Request-level responses: 400 (malformed envelope, batch over 500), 401 (bad/missing bearer), 404 (feature disabled), 429 (60 requests/min per token).

Every processed user is audited as provisioning.user.created / .skipped / .rejected, attributed to the token's id. Counted by schleuse_provisioning_users_total{result} (dry runs land under result="dry_run").

CSRF ​

MethodPathPurpose
GET/api/csrf-tokenGet a CSRF token. Also sets the csrf_token cookie.

The token is rotated on login. OIDC endpoints are exempt (they use PKCE + state).

A small but polished sidecar identity provider. · llms.txt