Skip to content

Authentication

Statement

ClaimGuard authenticates two distinct classes of caller:

  • Human users authenticate with email + password and receive a signed JWT for subsequent requests. Passwords are bcrypt-hashed at cost factor 12 (see Cryptography).
  • Programmatic clients authenticate with API keys that are hashed at rest, scoped per-key, rate-limited per-key, and optionally IP-allowlisted.

There is no third-party SSO, OAuth provider, magic-link, or passwordless flow in production today. Adding SAML / OIDC SSO is a roadmap item gated on the first enterprise customer that requires it.

The authentication path is partial rather than fully implemented because of one specific gap: a password_plain column exists on the users table to support an admin password-recovery feature inherited from earlier prototyping. The bcrypt hash is the canonical credential, but the plaintext is also stored. Removing this column and the feature that reads it is the largest single follow-up on this page.

Implementation

Human user — email + password

  • Login: POST /api/auth/login (server/src/routes/auth.js).
  • Looks up the user by lowercased email.
  • Verifies the supplied password against password_hash with bcrypt.compare (constant-time).
  • Updates last_login_at and resets failed_login_attempts on success.
  • On success, signs a JWT with JWT_SECRET and the configured expiry (JWT_EXPIRY, default 24h) — see Session management.
  • The route is wrapped by the global IP-based rate limiter (per docs/security/README.md — auth endpoints are rate-limited by IP, with admin endpoints rate-limited per-user post-auth).
  • Registration / user creation: users are created by an admin via POST /api/users (server/src/routes/users.js). Self-service registration does not exist. Bcrypt cost is centralized as BCRYPT_ROUNDS = 12.
  • Password change: PUT /api/users/:id accepts a new password, re-hashes with bcrypt, and (currently) also writes the plaintext to password_plain — see the gap section below.
  • Account lockout: there is a failed_login_attempts column but no enforced threshold-based lockout in code today. Brute-force resistance comes from the IP rate limiter and bcrypt's per-attempt cost.

Roles

The application uses four built-in roles, defined and enforced in server/src/middleware/auth.js:

Role Granted by Capability
SUPER_ADMIN Bootstrap / another super-admin Cross-org administration, system-level routes, can create other super-admins.
ADMIN A super-admin or an admin in the same org Org-scoped administration: user management, configuration, claim review.
REVIEWER An admin or super-admin Default analyst role: claim review and notes.
EXEC_VIEWER An admin or super-admin Read-only dashboard access.

Role checks are surfaced as middleware: requireAuth, requireAdmin (matches ADMIN or SUPER_ADMIN), requireSuperAdmin. See Authorization.

Programmatic clients — API keys

API keys are issued via POST /api/api-keys (server/src/routes/apiKeys.js) by an admin in the issuing org. The generation and validation flow:

  • The key is generated as KEY_PREFIX + crypto.randomBytes(...).toString('hex') in server/src/services/apiKeyService.js.
  • Only a SHA-256 hash of the key is stored on the row (api_keys.key_hash); a short non-secret prefix (key_prefix) is also stored for at-a-glance identification.
  • The raw key is returned to the caller once at creation time and is never recoverable from the database afterwards.
  • Every API request is matched by hashing the supplied key and looking up key_hash. Constant-time digest comparison via SHA-256 + exact-match indexed lookup.

Each key carries:

  • Per-key scopes — the scopes field on the row is consulted in server/src/middleware/apiKeyAuth.js to authorize specific endpoints. Routes that require API-key auth check the scope set before serving.
  • Per-key rate limitsrate_limit_per_minute on the row is applied dynamically by the API-key auth middleware (cached for 60 seconds to avoid a hot DB read).
  • Optional IP allowlist — if set, requests from off-list IPs are rejected before the request is routed.
  • Org scoping — every API key is bound to an org_ref; every query made under the key is org-scoped via the same pattern as human-user requests (see Authorization).

Token shape

The JWT payload is intentionally minimal:

  • id — the user's UUID.
  • email, app_role, org_ref — snapshot values captured at sign time. They are not the source of truth at request time.
  • iat / exp — RFC 7519 timestamps.

requireAuth (server/src/middleware/auth.js) re-fetches the user row from the DB on every request and rebuilds req.user from the current values of app_role, org_ref, email, display_name, and full_name. The JWT's role and org fields are not consulted by downstream authorization checks. A token issued before a role change or org transfer therefore reflects the new role/org on the user's next request — no re-login required, and no roadmap work needed for this case. See Session management for the same behavior on is_active.

Status

partial — verified 2026-04-30.

What's in place:

  • Bcrypt-12 password storage with mandatory JWT_SECRET for token signing.
  • Four enforced roles; org-scoping consistent with the authorization page.
  • Per-request DB re-fetch in requireAuthapp_role, org_ref, and is_active are read from the live users row on every request, so role changes and org transfers take effect on the user's next call without a re-login.
  • Hashed-at-rest API keys with per-key scopes, per-key rate limits, and optional IP allowlisting.
  • IP rate limiter on the login endpoint.
  • No fallback or development-only auth path.

Known gaps

  • password_plain column. The users table currently has a password_plain TEXT column written on every password set/change alongside password_hash (server/src/routes/users.js lines 287-288). The same column is returned by GET /api/users/:id to admins within the same org and to super-admins across orgs (server/src/routes/users.js lines 92-114). The feature exists for admin password recovery in an internal-tools context.

Implications: an admin can read peer plaintext passwords; database exfiltration leaks plaintext, eliminating the bcrypt defense; this contradicts the standard security claim "passwords are never stored in plaintext."

Remediation plan (queued as a code change, not yet shipped):

  1. Replace the admin "password recovery" UX with an admin-initiated forced password reset (admin clicks "send reset link" or "set temporary password"; the user resets on first login).
  2. Remove password_plain from every read path (GET /api/users/:id).
  3. Migration to ALTER TABLE users DROP COLUMN password_plain once no caller depends on it.
  4. Remove the password_plain writes in users.js lines 287-288 and the schema column in 005_users_auth_table.sql.
  5. Update Cryptography to drop the gap line and re-claim the clean bcrypt-only story.
  6. No threshold-based account lockout. failed_login_attempts is recorded but not yet acted on. The bcrypt cost + IP rate limiter bound brute-force throughput, but a per-account lockout would close the credential-stuffing path more decisively.
  7. No SSO (SAML / OIDC). Email + password is the only human auth flow. Roadmap item.
  8. No MFA on application logins. GCP-side MFA covers cloud administration (see Privileged access); the application itself does not enforce MFA on user logins.
  9. No per-token revocation list. Logout is client-side disposal of the JWT; a stolen token is valid until exp. See Session management for the corresponding gap on that page.
  10. API-key issuance is not yet self-service — only an admin can create them via the API, and there is no UI surface yet. Documented here so it doesn't surprise an auditor reading server/src/routes/apiKeys.js.

Roadmap

  • Remove password_plain as the highest-priority code-fix item named on this page. Tracked alongside the next batch of application-security work.
  • Account lockout with a documented threshold and a per-org-configurable parameter.
  • MFA on the application — TOTP first, WebAuthn next.
  • SSO (SAML / OIDC) gated on the first enterprise customer requirement.