Authentication & Authorization
Eviworx ITSM uses JWT (JSON Web Tokens) with HttpOnly cookies, Microsoft Entra ID SSO, API keys for external systems and a granular RBAC system with 28+ permission modules.
Authentication Methods
1. Email/Password (Classic)
POST /api/auth/login
{
"email": "admin@company.com",
"password": "SecurePassword123!"
}
Response (200 OK)
{
"user": {
"id": "clx...",
"email": "admin@company.com",
"name": "Admin User",
"role": "ADMIN",
"avatar": "https://..."
},
"expiresAt": "2026-01-28T11:45:00Z",
"sessionConfig": {
"accessTokenMinutes": 60,
"maxSessionHours": 12,
"idleTimeoutMinutes": 30
}
}
IMPORTANT: The JWT token is set as HttpOnly cookies (auth_token + refresh_token). Tokens are NOT returned in response body. JavaScript CANNOT access these cookies (XSS protection).
Account status: Only active accounts can sign in. Blocked and archived accounts are rejected on every sign-in path: password login, 2FA completion and token refresh return 403 ACCOUNT_DEACTIVATED, the Microsoft sign-in aborts and returns to the sign-in page with a translated message. The response is identical for both states so that it is not visible from outside which one applies; the audit records the reason (DENIED with ACCOUNT_LOCKED or ACCOUNT_ARCHIVED). A sign-in never changes the account status — an archived account is only unarchived in user management (users.archive). lastLogin is set only after the sign-in is complete, i.e. after the second factor when 2FA is active. The three account states: Users & Roles API.
A status change takes effect immediately: blocking an account ends all of its access — running sessions, refresh tokens, push subscriptions and open real-time connections; the permission cache is cleared. Requests, the socket handshake and the refresh then answer 403 ACCOUNT_DEACTIVATED instead of 401 SESSION_REVOKED, so the reason sits with the account rather than the session. If the account is unblocked again, the old session stays terminated and reports 401 SESSION_REVOKED once more — an already severed access does not come back through it.
An active account additionally needs a usable role: if it is missing or deactivated, password login and token refresh answer 403 NO_USABLE_ROLE, and the Microsoft sign-in aborts with the same statement. The account status stays active — the cause lies with the role, and the message says so. If the role of a signed-in user is deactivated, their access ends just as immediately as on an account block; the next refresh names that reason.
sessionConfig
Session timings are owned by the server and delivered to the client — on login, after 2FA completion and via GET /auth/me. A client does not configure them itself but derives from them when to show the inactivity warning and when to refresh proactively. It contains exactly the three values that drive a client decision:
| Field | Meaning |
|---|---|
accessTokenMinutes | Access token lifetime — basis for the proactive refresh interval |
maxSessionHours | Hard session end (not extendable) |
idleTimeoutMinutes | Inactivity limit — basis for the warning dialog and auto logout |
Response when MFA/TOTP is enabled (no cookie set!)
{
"requiresTwoFactor": true,
"twoFactorEnabled": true,
"requiresSetup": false,
"twoFactorPendingToken": "pending-jwt-token...",
"expiresAt": "2026-03-17T12:05:00Z",
"user": { "id": "...", "email": "admin@company.com" }
}
Then the 2FA code must be verified:
POST /api/auth/2fa/complete-login
{
"twoFactorPendingToken": "pending-jwt-token...",
"code": "123456"
}
Turnstile CAPTCHA (after 3 failed attempts)
After 3 failed login attempts within 15 minutes, a Cloudflare Turnstile CAPTCHA is required. The login request must then include an additional field:
{
"email": "admin@company.com",
"password": "SecurePassword123!",
"captchaToken": "turnstile-response-token..."
}
2. MFA / Two-Factor Authentication (TOTP)
Eviworx provides native TOTP-based MFA (SHA-256, FIPS 140-2 compatible). TOTP secrets are stored encrypted with AES-256-GCM (TWO_FACTOR_ENCRYPTION_KEY).
| Method | Endpoint | Description |
|---|---|---|
POST | /api/auth/2fa/setup | Initialize 2FA → { secret, qrCodeDataUrl, backupCodes } (the codes are shown only here) |
POST | /api/auth/2fa/verify-setup | Verify first code & activate 2FA → 204 |
POST | /api/auth/2fa/complete-login | Complete login after 2FA verification |
POST | /api/auth/2fa/disable | Disable 2FA (password + code required) → 204 |
POST | /api/auth/2fa/backup-codes | Regenerate backup codes (password + code) → { backupCodes }; the old ones expire |
GET | /api/auth/2fa/status | 2FA status → { enabled, required, verifiedAt, backupCodesRemaining, isEntraIdManaged } |
POST | /api/admin/users/:id/reset-2fa | Admin: reset 2FA for user → { user } (permission users.reset2FA; self-reset blocked) |
The /api/auth/2fa/* routes apply to the logged-in user themselves; the admin reset requires users.reset2FA. An admin cannot reset their OWN 2FA via the reset endpoint (400 SELF_RESET_NOT_ALLOWED — backup codes or a second admin required); an account without active 2FA returns 400 TWO_FACTOR_NOT_ENABLED.
3. Microsoft Entra ID SSO
# Step 1: Check if SSO is enabled
GET /api/auth/entra-id/config/public
Response
{
"isEnabled": true,
"tenantId": "your-tenant-id",
"clientId": "your-client-id"
}
# Step 2: Get OAuth URL
GET /api/auth/entra-id/login
Response
{
"authUrl": "https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/authorize?client_id=...&state=abc123..."
}
# Step 3: User authenticated by Microsoft, redirected to:
GET /api/auth/entra-id/callback?code=OAUTH_CODE&state=abc123...
# Callback validates state (CSRF protection), exchanges code for token
# User is redirected to /login?oauth=success
# JWT set in HttpOnly cookie
Configuration & user sync
The EntraID routes are mounted under two prefixes: /api/auth/entra-id (OAuth login) and /api/entra-id (admin config & sync). The user sync runs as a built-in daily job ("Entra ID User Sync", 01:00 UTC) and can additionally be triggered by hand at any time (202 with runId); without an active integration it does nothing. It works as a background job in the backend. Only one sync runs at a time: triggering it while one is already running returns 409 ENTRA_ID_SYNC_ALREADY_RUNNING and carries runId and progress — a client attaches to the running job instead of starting a second one. Progress is queryable. Sign-in uses the OAuth 2.0 authorization code flow; the user sync queries Microsoft Graph with the credentials of the app registration (client credentials).
| Method | Endpoint | Description |
|---|---|---|
GET | /api/auth/entra-id/config/public | Public SSO status (for login page) |
GET | /api/auth/entra-id/login | OAuth URL (with state) |
GET | /api/auth/entra-id/callback | OAuth callback (validates state, sets cookie) |
GET/PUT | /api/entra-id/config | Read/save configuration (admin) |
POST | /api/entra-id/test-connection | Test connection (admin) |
POST | /api/entra-id/sync | Trigger user sync by hand (202; if one is already running: 409 ENTRA_ID_SYNC_ALREADY_RUNNING) |
GET | /api/entra-id/sync-progress | Live progress of the running sync |
GET | /api/entra-id/sync-status | Status/result of the last sync |
Microsoft sign-in and Entra-managed accounts
The Microsoft sign-in matches a login to an account solely via the immutable Microsoft ID (oid). Linking happens in the sync only: anyone the sync has not linked is rejected by the callback — including a passwordless email contact with a matching address. The sign-in then ends on the sign-in page with the note OAUTH_NOT_SYNCED; the audit records the same reason (OAUTH_LOGIN, DENIED). Name and email of a linked account are likewise maintained by the sync alone — this keeps the directory the leading source, and a sign-in cannot overwrite the data maintained there.
An account managed by the sync signs in via Microsoft only: password login answers 401 ACCOUNT_REQUIRES_PASSWORD_SETUP, a password change 400 ACCOUNT_NO_PASSWORD. When linking, the sync removes a local password along with local two-factor sign-in and ends open sessions — so no second sign-in path remains that would bypass the directory's rules. The internal system account under which automated operations run does not sign in on any path.
4. API Keys (for External Systems)
External systems authenticate via the X-API-Key header (no Bearer token). An API key is a role-based machine identity and receives the permissions of its assigned role (unified actor):
curl https://your-instance.com/api/tickets \
-H "X-API-Key: apk_8sJ2...your-key..."
Creation, management (settings.manageRoles), fields, plaintext key handling (prefix apk_, shown only once), IP whitelist, rate limit and the runtime checks are fully documented at API Keys.
Forgot / Reset Password
| Method | Endpoint | Description |
|---|---|---|
POST | /api/auth/forgot-password | Request password reset email → 204 (also for unknown addresses or blocked accounts) |
POST | /api/auth/reset-password | Set password with reset token → 204 |
# Request password reset
POST /api/auth/forgot-password
{ "email": "user@company.com" }
# Reset password (with token from email)
POST /api/auth/reset-password
{ "token": "reset-token-from-email", "newPassword": "NewSecure123!" }
Invitation Email
When an admin creates a new user and sends an invitation email, the user receives a link for password setup:
| Method | Endpoint | Description |
|---|---|---|
GET | /api/auth/validate-invitation | Validate invitation token → { valid: true, email, userName } or { valid: false, reason } with reason INVALID_OR_EXPIRED | USER_NOT_FOUND | ALREADY_USED |
POST | /api/auth/setup-password | Set password via invitation link → 204 |
On a blocked or archived account, reset-password and setup-password do not set a password. The response is 400 INVALID_RESET_TOKEN or 400 INVALID_INVITATION_TOKEN, as for an invalid token, and the token is consumed afterwards; validate-invitation returns { valid: false, reason: INVALID_OR_EXPIRED } for such accounts. This way it is not visible from outside whether an account is blocked — the audit records the reason (DENIED with ACCOUNT_LOCKED or ACCOUNT_ARCHIVED).
Session Management
Active Sessions
| Method | Endpoint | Description |
|---|---|---|
GET | /api/auth/sessions | List all active sessions → { data } |
DELETE | /api/auth/sessions/others | Terminate all other sessions → { revokedCount } |
DELETE | /api/auth/sessions/:sessionId | Terminate individual session (sessionId = sid) → 204 |
Session identity (sid)
Every session has a stable UUID (sid), carried in the access token and unchanged across all token rotations. It is the ID the sessions list works with and the one used to terminate a session. A revoke therefore hits the session regardless of how often the token has rotated since. Two logins within the same second also remain separate sessions.
GET /api/auth/sessions
{
"data": [
{
"id": "3f2a91c4-8d17-4b6e-9c05-1a7de2b8f430",
"deviceName": "Chrome on Windows",
"browser": "Chrome",
"os": "Windows",
"ipAddress": "192.0.2.10",
"createdAt": "2026-07-16T08:00:00.000Z",
"lastRefresh": "2026-07-16T11:30:00.000Z",
"expiresAt": "2026-07-16T20:00:00.000Z",
"isCurrent": true
}
]
}
| Field | Meaning |
|---|---|
id | the sid (UUID) — stable, to be used for DELETE /sessions/:sessionId |
expiresAt | the hard session end (12 h from login); unchanged by refreshes |
lastRefresh | last token renewal — the activity indicator |
Your own session cannot be terminated via revoke (400 CANNOT_REVOKE_CURRENT) — logout is for that. An unknown sid returns 404 SESSION_NOT_FOUND, a request without a valid session 400 NO_SESSION.
Token Refresh
POST /api/auth/refresh
Response (200 OK)
{
"user": {
"id": "clx...",
"email": "admin@company.com",
"name": "Admin User"
},
"expiresAt": "2026-01-28T12:45:00Z"
}
Note: The new token is set as HttpOnly cookie again. Frontend needs to store NOTHING - cookie is sent automatically with every request.
Rotation, grace window & reuse detection
Every refresh rotates the refresh token: the caller receives a new one, the old one is devalued. That is standard — but it has a catch: if the response is lost in transit (timeout, dropped connection), the server has already rotated while the client still holds the old token. Without a countermeasure the session would be dead. Therefore:
| Time of redemption | Behavior |
|---|---|
| within 60 s after rotation | The old token is accepted and rotated again — the same session continues (chain). A lost response therefore costs nothing. |
| after that (up to 10 min) | Reuse suspicion: a long-replaced token reappears — that points to a stolen token. The ENTIRE session is terminated immediately (real-time connections dropped), audit entry REFRESH_TOKEN_REUSE. Subsequent requests get 401 SESSION_REVOKED. |
| even later | The token is simply unknown → 401 REFRESH_TOKEN_EXPIRED. |
| Session was terminated | Takes precedence over everything else: if the session has been terminated in the meantime (sessions UI, password change, reuse detection), EVERY refresh is rejected — including a redemption inside the grace window. Otherwise a just-revoked access could rise again via the old token. |
The session identity (sid) stays the same across all rotations — the sessions list and revoke hang off it, not off the rotating token. Hence: once a session has been terminated, no path ever issues tokens for it again.
Logout
POST /api/auth/logout
Response
{
"message": "Logged out successfully"
}
Automatically:
- Access token is blacklisted (Redis, until expiry)
- Refresh token is invalidated, all access tokens of the session become invalid immediately, WebSocket connections disconnected
- Both HttpOnly cookies are cleared
- Audit log: LOGOUT (SUCCESS)
Logout is deliberately lenient: The endpoint requires NO valid authentication and ALWAYS answers 200 — cookies are cleared in every case. Reason: if logout required a valid access token, you could not log out with an expired or revoked one; the HttpOnly cookies (including the still-valid refresh token) would stay in the browser, and the frontend cannot remove them itself. An IP-based rate limit sits in front, because the endpoint is reachable unauthenticated.
Lenient does not mean gullible, though: the destructive side effects — dropping WebSocket connections and discarding the permission cache — run ONLY when the identity is proven. Proven means: a validly signed access token, or a refresh token cookie whose server-side record points to the same user. A made-up token therefore still yields 200 and cleared cookies, but does not throw someone else out of their connections.
Re-login without logout
If someone logs in again without logging out first (same browser, old cookie still present), the previous session is terminated completely — not just its refresh token: the old access tokens become invalid immediately, the WebSockets of the old session are disconnected, and the sessions list shows only the new session. Push notifications follow along: if the same user logs in again, the subscription moves to the new session; if the user changes in the same browser, it is removed.
Get Current User
GET /api/auth/me
Response
{
"id": "clx...",
"email": "admin@company.com",
"name": "Admin User",
"roleId": "clx...",
"roleName": "admin",
"roleDisplayName": "Administrator",
"roleColor": "#1F6FEB",
"roleIsSystem": true,
"avatar": "https://...",
"language": "de",
"theme": "dark",
"timezone": "Europe/Berlin",
"dateTimeFormat": "dd.MM.yyyy HH:mm",
"showRealtimeUpdateToasts": true,
"workspaceSettings": { "version": 2, "dashboard": { "widgets": [ ... ] }, "workList": { "scope": "own" } },
"agentGroups": [{ "id": "clx...", "name": "IT Support L1", "color": "#3B82F6" }],
"createdAt": "2026-01-15T09:00:00Z",
"profile": {
"firstName": "Admin",
"lastName": "User",
"phone": "+49...",
"department": "IT",
"location": "Munich",
"isActive": true,
"isSyncedFromEntraID": false,
"lastLogin": "2026-08-14T07:12:00Z"
},
"sessionConfig": {
"accessTokenMinutes": 60,
"maxSessionHours": 12,
"idleTimeoutMinutes": 30
}
}
sessionConfig is not part of the user object but describes the session timings (see above) — a client should strip it before storing the user. The caller's permissions are returned by GET /api/auth/permissions (see below).
Get Permissions Only
GET /api/auth/permissions
Response
{
"tickets": {
"viewAll": true,
"viewOwn": true,
"create": true,
"createForOthers": true,
"editStatus": true,
"editPriority": true,
"editCategory": true,
"assign": true,
"editAll": true,
"viewInternal": true,
"delete": true,
"restore": true,
"viewDeleted": true
},
"incidents": {
"viewAll": true,
"create": true,
"editAll": true,
"changeStatus": true,
"requestClosure": true,
"delete": true
},
"problems": { ... },
"changes": { ... },
"assets": { ... },
"workflows": { ... },
"cronjobs": { ... },
"contracts": { ... },
"licenses": { ... },
"knowledgeBase": { ... },
"users": { ... },
"roles": { ... },
"settings": { ... },
"audit": { ... }
}
Token Lifetime & Security
| Property | Value |
|---|---|
| Access Token | 60 min (configurable: ACCESS_TOKEN_EXPIRY_MINUTES) |
| Refresh Token | 100 min (configurable: REFRESH_TOKEN_EXPIRY_MINUTES). Deliberately tight: the latest legitimate use is the refresh after an idle phase at ~60 min token age — longer lifetimes would only add attack surface for stolen tokens. |
| Max Session | 12h hard limit, backend-enforced — NOT extendable even via refresh (configurable: SESSION_MAX_HOURS) |
| Idle Timeout | 30 min of inactivity → auto logout (configurable: IDLE_TIMEOUT_MINUTES). The value is owned by the backend and handed to the client; enforcement happens in the frontend. |
| Rotation | Every refresh rotates the refresh token; the old one stays redeemable for 60 s (grace) and is then kept for 10 min as a tripwire (reuse detection). |
| Storage | HttpOnly Cookies (auth_token + refresh_token) |
| SameSite | lax (CSRF-Protection) |
| Secure | true in production; explicitly overridable via COOKIE_SECURE (e.g. internal HTTP deployment) |
| Blacklist | Redis (on logout, until token expiry) |
| Password Hashing | PBKDF2-SHA512, 210k iterations (FIPS 140-2 compatible) |
Authorization (RBAC)
Whether a request comes via session cookie (user) or X-API-Key (API key with a role) — both go through the same permission check against their role's permission matrix. The current permissions of the logged-in actor are returned by GET /api/auth/permissions (see above).
The full, authoritative permission catalog (all modules/actions, system roles, the three check levels, loading & caching) is documented centrally at Permissions & RBAC. API key management (creation, fields, rate limit, IP whitelist, runtime checks) is documented at API Keys.
Profile Management
Update Own Preferences
PUT /api/auth/profile
{
"theme": "dark",
"timezone": "Europe/Berlin",
"dateTimeFormat": "dd.MM.yyyy HH:mm",
"showRealtimeUpdateToasts": true,
"workspaceSettings": {
"version": 2,
"dashboard": { "widgets": [{ "id": "open-tickets" }, { "id": "sla-breaches", "hidden": true }] },
"workList": { "scope": "own", "sort": "dueDate", "groupBy": "dueDate" }
}
}
- This endpoint is the path for the signed-in user's personal preferences: theme (light | dark | system), timezone and dateTimeFormat (each also accepting "system" for the global default resp. "local" for the browser time zone), showRealtimeUpdateToasts and workspaceSettings.
- workspaceSettings is the workplace personalization: dashboard.widgets carries order (array order) and visibility ({ id, hidden? }, at most 50 entries), workList the defaults of the work list (scope: own | group | substitute, sort: dueDate | priority | updatedAt, groupBy: dueDate | type | none). version is required. null resets the user to the preset derived from their permissions; missing widgets are appended by the UI in preset order. A PUT replaces the whole block — whoever writes only dashboard must send workList along from the stored state.
- Unknown fields are rejected with 400. Master data has its own paths with their own validation — name and avatar via PATCH /api/users/:id, the language via PUT /api/users/:id/language.
- Changes are audited (field diff), but only when something actually changes — saving without a change creates no entry.
Change Password
POST /api/auth/change-password
{
"currentPassword": "OldPassword123!",
"newPassword": "NewSecurePassword456!",
"confirmPassword": "NewSecurePassword456!"
}
Response: 204 with no body. The same applies to forgot-password, reset-password and setup-password. forgot-password deliberately returns 204 for unknown addresses too — the API does not reveal which accounts exist.
Frontend Integration
Login Flow (JavaScript)
// 1. Login
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // ← IMPORTANT! So the cookie gets set
body: JSON.stringify({
email: 'admin@company.com',
password: 'your-password'
})
});
const { user, expiresAt, sessionConfig } = await response.json();
// Token is now in an HttpOnly cookie (invisible to JavaScript).
// sessionConfig tells you the server's timings — do not hardcode your own.
// 2. API request (cookie is sent automatically)
const tickets = await fetch('/api/tickets', {
credentials: 'include' // ← IMPORTANT!
}).then(r => r.json());
// 3. Load permissions (for UI)
const permissions = await fetch('/api/auth/permissions', {
credentials: 'include'
}).then(r => r.json());
// 4. Proactive token refresh — derive the interval from sessionConfig
// (here: refresh once ~80% of the access token lifetime has passed)
const refreshEveryMs = sessionConfig.accessTokenMinutes * 60 * 1000 * 0.8;
setInterval(async () => {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
// Only a 401/403 means the session is really gone. A network error or a
// 5xx is transient — retry later instead of logging the user out.
if (res.status === 401 || res.status === 403) redirectToLogin();
}, refreshEveryMs);
// 5. Logout — always succeeds, cookies are cleared even without a valid token
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
SSO Integration (Entra ID)
Setup Steps
- Azure Portal: Create app registration
- Redirect URI: https://your-domain.com/api/auth/entra-id/callback
- Generate client secret
- Permissions: User.Read, GroupMember.Read.All
- Create base group (e.g., "ITSM-Users")
- Configure in Eviworx UI
The Entra ID clientSecret is stored encrypted on save (AES-256-GCM). GET responses never return it (not even masked) — the "configured" indicator uses a _hasClientSecret flag; to keep an unchanged secret simply omit the field (the stored value is preserved). An already-encrypted value (prefix enc:v1:) is rejected as input — 400 ENTRA_ID_SECRET_CIPHERTEXT, so a replayed ciphertext does not get encrypted a second time. OAuth login and user sync decrypt it at runtime.
Access and role mapping
- Only accounts created by the Entra sync from the base group have access; changes to the group take effect with the next sync — daily at 01:00 UTC or immediately by hand. The Microsoft sign-in itself does not check group membership and does not create accounts: it matches the sign-in to a synchronized account solely via the Microsoft ID (oid) and admits an active account only if its role carries usable permissions.
- The role mapping lives on the role: field entraIDRoleId holding the ID of an Entra group. A group belongs to at most one role; only active roles count.
- One match: the account gets that role. Several matches: the role with the lowest priority number wins.
- No match: conflict (entraIDConflict = true). Existing accounts keep their role, new ones get END_USER.
Sync flow, locks on leaving the base group, safety thresholds and role selection for conflict accounts: User Management.
Aborted Microsoft sign-in: If a Microsoft sign-in fails, it always ends on the sign-in page: the callback redirects to /login?oauthError=<CODE> and the UI shows the reason in the sign-in language. The codes are a fixed set: OAUTH_MISSING_STATE, OAUTH_INVALID_STATE, OAUTH_STATE_EXPIRED and OAUTH_STATE_ERROR (the callback validates its state, valid for 5 minutes), OAUTH_INTEGRATION_DISABLED (SSO is switched off), OAUTH_NOT_SYNCED (the sync does not know this identity), ACCOUNT_DEACTIVATED, NO_USABLE_ROLE plus OAUTH_CALLBACK_FAILED for everything else; an unknown value is treated like OAUTH_CALLBACK_FAILED. Rejections at the account are recorded in the audit under the same code (OAUTH_LOGIN, DENIED).
Error Handling
Every rejected request answers in the same shape: error carries the message text, errorCode the machine-readable reason from the table below, details additional information where applicable. A client always evaluates errorCode, never the message text — including the token rejections, which use the same shape.
| Error Code | HTTP Status | Description |
|---|---|---|
INVALID_CREDENTIALS | 401 | Email or password incorrect |
ACCOUNT_DEACTIVATED | 403 | Account blocked or archived — the same response for both states |
NO_USABLE_ROLE | 403 | The account is active but its role carries no usable permissions (no role, a deactivated role or an unusable permission matrix). Applies to login, refresh and the Microsoft sign-in. |
ACCOUNT_REQUIRES_PASSWORD_SETUP | 401 | No password login possible: the account is managed via Entra ID (sign-in only via Microsoft) or has no password set yet |
TOKEN_EXPIRED | 401 | Access token expired → refresh is the correct next step |
TOKEN_REVOKED | 401 | Access token blacklisted via logout |
SESSION_REVOKED | 401 | Session terminated (sessions UI, password change, reuse detection) |
MAX_SESSION_EXCEEDED | 401 | 12h hard limit reached — re-login required, refresh does not help |
INVALID_TOKEN | 403 | Signature/format invalid (NOT "expired" — that is TOKEN_EXPIRED with 401) |
MISSING_USER_ID | 403 | The token is formally valid but carries no user id — it is unusable as an access token |
NO_TOKEN | 401 | The request carries neither an auth_token cookie nor an API key |
NO_REFRESH_TOKEN | 401 | No refresh_token cookie in the request |
REFRESH_TOKEN_EXPIRED | 401 | Refresh token unknown or expired |
REFRESH_TOKEN_REUSE | 401 | An already-replaced refresh token was redeemed again → entire session terminated |
AUTH_SERVICE_ERROR | 503 | Redis unreachable; access is denied when in doubt |
AUTH_RATE_LIMIT_EXCEEDED | 429 | Too many attempts (Retry-After header). The account lockout after too many failed attempts carries the same code; no CAPTCHA is requested then, because it changes nothing about a running lockout. Says NOTHING about session validity — a client must NOT log out on this but retry later. Limits and ENV overrides: Security → Brute force protection |
CAPTCHA_REQUIRED | 400 | Login requires a CAPTCHA (details.requiresCaptcha: true) |
CAPTCHA_INVALID | 400 | CAPTCHA verification failed (details.requiresCaptcha: true) |
INVALID_CODE · INVALID_BACKUP_CODE · CODE_ALREADY_USED | 401 / 400 | 2FA code rejected, with details.remainingAttempts. 401 when completing login, 400 in the authenticated step-up routes (verify-setup, disable, backup-codes) — a 401 there would trigger a token refresh. |
TWO_FACTOR_NOT_ENABLED | 401 / 400 | No 2FA is active for the account (also on admin reset) |
TWO_FACTOR_ALREADY_ENABLED | 400 | 2FA is already active — disable it before setting it up again |
SELF_RESET_NOT_ALLOWED | 400 | An admin cannot reset their own 2FA |
RATE_LIMITED | 429 | Too many failed 2FA attempts — the lockout expires server-side |
CANNOT_REVOKE_CURRENT | 400 | Own session cannot be revoked → use logout |
NO_SESSION | 400 | The request carries no valid session (session routes) |
SESSION_NOT_FOUND | 404 | No session with this sid |
CURRENT_PASSWORD_INCORRECT | 400 | Current password incorrect (on change-password) |
ACCOUNT_NO_PASSWORD | 400 | Password change on an account without a local password — e.g. an account managed via Entra ID |
ENTRA_ID_NOT_CONFIGURED | 400 | SSO not configured |
Best Practices
💡 Tips
1. Frontend Integration
- • ALWAYS use credentials: "include" with fetch()
- • DO NOT store token in localStorage (XSS risk)
- • Load permissions separately (not in token)
- • Derive the refresh interval from sessionConfig instead of hardcoding times
- • Log out only on 401/403 — network errors and 5xx are transient and must not end a session
2. SSO (Entra ID)
- • Base group for access control: only the sync creates accounts from it
- • Role mapping via entraIDRoleId on the role for automatic role assignment
- • The user sync runs daily at 01:00 UTC and can additionally be triggered by hand
- • Blocked and archived accounts are rejected on every sign-in path; unarchive only in user management
3. Security
- • Rotate JWT_SECRET (every 90 days, with JWT_SECRET_OLD)
- • Enforce HTTPS (secure cookie only on HTTPS)
- • Monitor audit logs (failed logins, brute force)
- • Token blacklist in Redis (on logout/compromise)
Note: Authentication is integrated with the Enterprise Audit System. All login/logout events are logged with IP, UserAgent, success/failure for compliance & forensics.