Eviworx
Docs

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.

🔐
Features
✓ JWT in an HttpOnly cookie (XSS protection)
✓ MFA/TOTP with backup codes (FIPS-compatible)
✓ Token rotation on every refresh (60 s grace)
✓ Reuse detection ends the entire session
✓ View and instantly terminate sessions (sid)
✓ Session timings from the server (sessionConfig)
✓ CAPTCHA after 3 failed attempts (Turnstile)
✓ Entra ID SSO (OAuth 2.0 authorization code)
✓ Permissions loaded per request from the role
✓ Audit log of all login/logout events

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:

FieldMeaning
accessTokenMinutesAccess token lifetime — basis for the proactive refresh interval
maxSessionHoursHard session end (not extendable)
idleTimeoutMinutesInactivity 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/setupInitialize 2FA → { secret, qrCodeDataUrl, backupCodes } (the codes are shown only here)
POST/api/auth/2fa/verify-setupVerify first code & activate 2FA → 204
POST/api/auth/2fa/complete-loginComplete login after 2FA verification
POST/api/auth/2fa/disableDisable 2FA (password + code required) → 204
POST/api/auth/2fa/backup-codesRegenerate backup codes (password + code) → { backupCodes }; the old ones expire
GET/api/auth/2fa/status2FA status → { enabled, required, verifiedAt, backupCodesRemaining, isEntraIdManaged }
POST/api/admin/users/:id/reset-2faAdmin: 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).

MethodEndpointDescription
GET/api/auth/entra-id/config/publicPublic SSO status (for login page)
GET/api/auth/entra-id/loginOAuth URL (with state)
GET/api/auth/entra-id/callbackOAuth callback (validates state, sets cookie)
GET/PUT/api/entra-id/configRead/save configuration (admin)
POST/api/entra-id/test-connectionTest connection (admin)
POST/api/entra-id/syncTrigger user sync by hand (202; if one is already running: 409 ENTRA_ID_SYNC_ALREADY_RUNNING)
GET/api/entra-id/sync-progressLive progress of the running sync
GET/api/entra-id/sync-statusStatus/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-passwordRequest password reset email → 204 (also for unknown addresses or blocked accounts)
POST/api/auth/reset-passwordSet 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-invitationValidate invitation token → { valid: true, email, userName } or { valid: false, reason } with reason INVALID_OR_EXPIRED | USER_NOT_FOUND | ALREADY_USED
POST/api/auth/setup-passwordSet 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/sessionsList all active sessions → { data }
DELETE/api/auth/sessions/othersTerminate all other sessions → { revokedCount }
DELETE/api/auth/sessions/:sessionIdTerminate 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
    }
  ]
}
FieldMeaning
idthe sid (UUID) — stable, to be used for DELETE /sessions/:sessionId
expiresAtthe hard session end (12 h from login); unchanged by refreshes
lastRefreshlast 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 redemptionBehavior
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 Token60 min (configurable: ACCESS_TOKEN_EXPIRY_MINUTES)
Refresh Token100 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 Session12h hard limit, backend-enforced — NOT extendable even via refresh (configurable: SESSION_MAX_HOURS)
Idle Timeout30 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.
RotationEvery 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).
StorageHttpOnly Cookies (auth_token + refresh_token)
SameSitelax (CSRF-Protection)
Securetrue in production; explicitly overridable via COOKIE_SECURE (e.g. internal HTTP deployment)
BlacklistRedis (on logout, until token expiry)
Password HashingPBKDF2-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

  1. Azure Portal: Create app registration
  2. Redirect URI: https://your-domain.com/api/auth/entra-id/callback
  3. Generate client secret
  4. Permissions: User.Read, GroupMember.Read.All
  5. Create base group (e.g., "ITSM-Users")
  6. 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

  1. 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.
  2. 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.
  3. One match: the account gets that role. Several matches: the role with the lowest priority number wins.
  4. 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_CREDENTIALS401Email or password incorrect
ACCOUNT_DEACTIVATED403Account blocked or archived — the same response for both states
NO_USABLE_ROLE403The 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_SETUP401No password login possible: the account is managed via Entra ID (sign-in only via Microsoft) or has no password set yet
TOKEN_EXPIRED401Access token expired → refresh is the correct next step
TOKEN_REVOKED401Access token blacklisted via logout
SESSION_REVOKED401Session terminated (sessions UI, password change, reuse detection)
MAX_SESSION_EXCEEDED40112h hard limit reached — re-login required, refresh does not help
INVALID_TOKEN403Signature/format invalid (NOT "expired" — that is TOKEN_EXPIRED with 401)
MISSING_USER_ID403The token is formally valid but carries no user id — it is unusable as an access token
NO_TOKEN401The request carries neither an auth_token cookie nor an API key
NO_REFRESH_TOKEN401No refresh_token cookie in the request
REFRESH_TOKEN_EXPIRED401Refresh token unknown or expired
REFRESH_TOKEN_REUSE401An already-replaced refresh token was redeemed again → entire session terminated
AUTH_SERVICE_ERROR503Redis unreachable; access is denied when in doubt
AUTH_RATE_LIMIT_EXCEEDED429Too 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_REQUIRED400Login requires a CAPTCHA (details.requiresCaptcha: true)
CAPTCHA_INVALID400CAPTCHA verification failed (details.requiresCaptcha: true)
INVALID_CODE · INVALID_BACKUP_CODE · CODE_ALREADY_USED401 / 4002FA 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_ENABLED401 / 400No 2FA is active for the account (also on admin reset)
TWO_FACTOR_ALREADY_ENABLED4002FA is already active — disable it before setting it up again
SELF_RESET_NOT_ALLOWED400An admin cannot reset their own 2FA
RATE_LIMITED429Too many failed 2FA attempts — the lockout expires server-side
CANNOT_REVOKE_CURRENT400Own session cannot be revoked → use logout
NO_SESSION400The request carries no valid session (session routes)
SESSION_NOT_FOUND404No session with this sid
CURRENT_PASSWORD_INCORRECT400Current password incorrect (on change-password)
ACCOUNT_NO_PASSWORD400Password change on an account without a local password — e.g. an account managed via Entra ID
ENTRA_ID_NOT_CONFIGURED400SSO 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.