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_hashwithbcrypt.compare(constant-time). - Updates
last_login_atand resetsfailed_login_attemptson success. - On success, signs a JWT with
JWT_SECRETand the configured expiry (JWT_EXPIRY, default24h) — 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 asBCRYPT_ROUNDS = 12. - Password change:
PUT /api/users/:idaccepts a new password, re-hashes with bcrypt, and (currently) also writes the plaintext topassword_plain— see the gap section below. - Account lockout: there is a
failed_login_attemptscolumn 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')inserver/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
scopesfield on the row is consulted inserver/src/middleware/apiKeyAuth.jsto authorize specific endpoints. Routes that require API-key auth check the scope set before serving. - Per-key rate limits —
rate_limit_per_minuteon 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_SECRETfor token signing. - Four enforced roles; org-scoping consistent with the authorization page.
- Per-request DB re-fetch in
requireAuth—app_role,org_ref, andis_activeare read from the liveusersrow 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_plaincolumn. Theuserstable currently has apassword_plain TEXTcolumn written on every password set/change alongsidepassword_hash(server/src/routes/users.jslines 287-288). The same column is returned byGET /api/users/:idto admins within the same org and to super-admins across orgs (server/src/routes/users.jslines 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):
- 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).
- Remove
password_plainfrom every read path (GET /api/users/:id). - Migration to
ALTER TABLE users DROP COLUMN password_plainonce no caller depends on it. - Remove the
password_plainwrites inusers.jslines 287-288 and the schema column in005_users_auth_table.sql. - Update Cryptography to drop the gap line and re-claim the clean bcrypt-only story.
- No threshold-based account lockout.
failed_login_attemptsis 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. - No SSO (SAML / OIDC). Email + password is the only human auth flow. Roadmap item.
- No MFA on application logins. GCP-side MFA covers cloud administration (see Privileged access); the application itself does not enforce MFA on user logins.
- 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. - 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_plainas 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.