Skip to main content

Authentication Contracts

Guarantees​

Session strategy decoupling​

  • Orthogonal to credentials: useAuth's session strategy (how the identity is persisted/validated/issued) is independent of the credential providers (login/signup/social/verification/mfa/magicLink). Any provider composes with any strategy — e.g. a Passport social provider issuing JWTs.
  • Single issuance path: every successful login (password, social, magic link, signup) goes through strategy.issue(c, userId); the auth middleware authenticates every request through strategy.authenticate(c). cookieSession mints a server-side session + cookie; jwtSession mints a bearer access token + refresh cookie and exposes /refresh.
  • Back-compat: a legacy adapter passed to useAuth is mapped to a session strategy internally, preserving its prior behavior.

Token Validation​

  • Required claims checked: iss, aud, exp are always validated
  • Algorithm enforcement: Only configured algorithms accepted; none always rejected
  • Clock skew tolerance: Configurable tolerance for exp, nbf, iat checks
  • Signature verification: All tokens are cryptographically verified

OIDC Compliance​

  • Discovery support: /.well-known/openid-configuration endpoint provided
  • PKCE required: Public clients must use PKCE (code_challenge); security.pkce.required extends this to all clients
  • PKCE plain rejected: code_challenge_method=plain is always rejected; only S256 is supported and advertised
  • State validation: state parameter validated on callback
  • Revocation (RFC 7009): POST /revoke invalidates a refresh token after client authentication
  • Introspection (RFC 7662): POST /introspect reports { active } and claims for access/refresh tokens after client authentication
  • Client secret hashing: Stored secrets prefixed scrypt$ are verified with scrypt; plaintext secrets are compared in constant time

OIDC Hardening​

  • Redirect URI component validation: redirect_uri is matched component-by-component (protocol, host, port, normalized path, and query/fragment when registered), never by prefix; an unregistered URI is rejected with 400 before any redirect is issued
  • Federated id_token verification: external id_tokens are signature-verified against the provider's JWKS (issuer + audience checked), the nonce is compared to the stored interaction nonce, and the id_token sub is cross-checked against the userinfo sub
  • External login resumes the interaction: a successful external callback (federated OIDC or backends.passport) establishes the provider session and continues the pending /authorize interaction to consent or the authorization-code redirect — the same completion path as email/password login; it never returns the user as a bare JSON body
  • Passport backend state binding: backends.passport carries the OAuth state/PKCE handle across the redirect→callback in a signed, httpOnly, short-lived cookie + state store; a callback with missing/expired/mismatched state is rejected before any token exchange
  • Correct at_hash: the id_token at_hash claim is the left-half of the hash matching the signing algorithm, computed whenever an access token is issued
  • Endpoint rate limiting: /token, /jwks, /introspect rate-limited per client/IP when security.rateLimiting is configured (KV-backed when a global KV exists)
  • Persistent stores by default: clients, codes, refresh tokens, consents, interactions, and state are KV-backed (with expiry-derived TTLs) whenever a global KV is registered; stores.type: "memory" forces in-memory
  • Dynamic registration is opt-in: POST /register returns 404 unless registration.enabled is true; an initialAccessToken, when set, is required as a Bearer token
  • Consent revocation + TTL: POST /consent/revoke clears a user's consent (one client or all) for an authenticated session; stored consents expire after security.consent.ttlSeconds (default 1 year)
  • login_hint escaping: dynamic values in the default login template are HTML-escaped

Account Security Flows (opt-in via useAuth)​

  • Password policy: when passwordPolicy is set, weak passwords are rejected with 422 on POST /signup and POST /password/reset; a built-in denylist of common passwords is enabled by default
  • MFA/TOTP: mfa adds /mfa/enroll, /mfa/enroll/confirm, and /mfa/verify; enrollment is two-step (created disabled, then confirmed). With requireOnLogin, /login returns { mfaRequired: true } (401) for enrolled users until a valid TOTP or single-use backup code is supplied; a used backup code triggers consumeBackupCode
  • Magic links: magicLink adds /magic-link/request (always returns { success: true }, anti-enumeration) and /magic-link/verify (single-use token, creates the session)
  • API keys: createApiKey/verifyApiKey/rotateApiKey/revokeApiKey store only a hash of the key; the raw key ([prefix_]<id>.<secret>) is returned once; verification returns a typed reason (not_found/expired/mismatch) on failure

Passkeys (WebAuthn)​

  • Same login path: a verified passkey assertion issues the session through the same completeLogin as /login (strategy.issue, CSRF token, onLogin), so it behaves identically under cookieSession and jwtSession; if the account no longer resolves (getUserById → null) no session is issued and the response is 401
  • Challenges are single-use, short-lived and purpose-bound: every options call mints a random challenge stored with its purpose (registration/authentication), expiry (challengeTtlMs, default 5 min) and binding; verify consumes it (KV del count decides a concurrent race) before any other check, so a challenge can never be redeemed twice, even after a failed attempt. A registration challenge never satisfies login and vice versa
  • Registration is bound to the signed-in user: both registration routes require an authenticated user, and the challenge's user must equal the verifying user; a credential id already registered (to anyone) is rejected with 409 and never re-assigned
  • Email-scoped login is account-bound: /passkey/login/options with { email } binds the challenge to that account (an unknown email binds to no account), so a different account's passkey cannot complete it; the response shape does not reveal whether the email exists
  • Full verification: clientDataJSON.type (webauthn.create/webauthn.get), challenge, and origin (exact match against origin) are checked; authenticatorData.rpIdHash must equal SHA-256 of rpId; user presence is always required and user verification is required when userVerification: "required"; the attestation (fmt none accepted) and the assertion signature are verified against the stored COSE public key (ES256, RS256, EdDSA). A non-empty userHandle must equal the credential's stored user handle
  • Signature-counter regression is rejected: after the signature is proven, an assertion whose counter is not strictly greater than the stored counter (unless both are 0) is rejected and reported via onCounterRegression; the stored counter only moves forward
  • Uniform login failures: every /passkey/login/verify failure is the same 401 Passkey authentication failed (no oracle), charges cost.failed (deferred, success is free), and feeds the login throttle keyed by credential id + IP when throttle is on
  • Credential privacy & ownership: the WebAuthn user handle is random (never the user id/email) and stable per user; list/rename/delete only ever touch the caller's own credentials (another user's id is 404); public keys are never returned by the API

Field-Level Write Enforcement (mass-assignment protection)​

  • Enforced allowlist: when fields.writable is configured, any table column not in the list is stripped from create/update bodies (single, batch, and POST /batch/upsert) before hooks and the database see it
  • Exemptions: the primary key and generatedFields are never stripped; non-column keys (relation payloads) pass through
  • Hook precedence: stripping happens before lifecycle hooks, so a server-side hook can still set protected fields
  • strictInput: with strictInput: true, unknown fields are rejected with 422 instead of being silently ignored

Password Storage​

  • Scrypt hashing: hashPassword derives a self-describing scrypt$N=..,r=..,p=..$salt$hash string with a per-hash random salt
  • Constant-time verification: verifyPassword compares using timingSafeEqual and returns false for any unparseable input
  • Rehash signalling: needsRehash returns true when stored parameters are weaker than the target (or the hash is unparseable)

Session Security​

  • Session rotation: Session ID rotated on login (prior session invalidated, prevents fixation)
  • Logout invalidation: All session tokens invalidated on logout
  • Bulk invalidation: Adapters may implement invalidateUserSessions(userId, exceptSessionId?); password reset uses it when logoutEverywhere is set
  • Indexed bulk invalidation: When the session store implements the per-user index (getByUser/deleteByUser — the Redis, Drizzle, and in-memory stores all do), bulk invalidation touches only that user's sessions; the scan-all getAll() path is a fallback for stores without the index
  • Session activity metadata: Login/signup store the client IP and user agent in session.data (ipAddress, userAgent); session use stamps session.data.lastActiveAt, throttled to at most one persisted write per minute per session and fire-and-forget (activity tracking never blocks or fails auth)
  • Cookie security: HttpOnly, Secure (in production), SameSite attributes set

Multi-Tenant​

  • Issuer isolation: Same sub from different iss = different users
  • Issuer whitelist: Only configured issuers accepted

Field-Level Read Masking​

  • Allowlist semantics: When fields.readable is configured on a resource, only the listed table columns may appear in any response
  • Universal application: Masking applies to list, get, create, update, batch, and search responses, plus every subscription event (existing, added, changed) and the initial subscription snapshot
  • No client bypass: A hidden column cannot be recovered via ?select= or by subscribing — masking is enforced server-side after projection
  • Column-only: Only table columns are stripped; relation keys, computed values, and internal markers (_etag, _optimisticId) pass through

Scope Combination (Fail-Closed)​

  • Empty scope denies on every path: An empty resolved scope (emptyScope(), an empty rsql ``, or and()/or() that reduces to empty) is an explicit deny. It is enforced identically for reads and writes: combineScopes emits an internal deny sentinel that converts to 1 = 0 in SQL and false in memory, so GET /, GET /:id, /count, /aggregate, /search, and live subscribe matching all return nothing, and writes return 403. It never degrades to an absent WHERE clause (every row)
  • User filter cannot widen an empty scope: A client ?filter= is AND-ed into a concrete scope but is dropped against an empty scope — the deny wins. A denied user cannot probe rows by supplying a targeted filter (e.g. ?filter=id==<secret>)
  • Unconfigured ≠ deny: An operation with no scope function (and not granted by public) resolves to allScope() for authenticated users, not empty. Deny must be expressed explicitly (return emptyScope()). Each resource with such operations logs a one-time console.warn at useResource time naming them (not under NODE_ENV=test); declaring any scope function for the operation, including allScope(), silences it. The warning does not change enforcement
  • Subscriptions store the deny sentinel: The scope frozen onto a subscription (and refreshed by sse.scopeRecheckMs) maps an empty scope to the deny sentinel, so live added/changed events for an empty-scope subscriber match nothing regardless of the subscriber's own filter

Relation Scope Enforcement​

  • Included relations respect target scope: On the read path (GET / and GET /:id with ?include=), each included relation AND-s the target resource's read scope (resolved for the effective/impersonated user) into its query — a relation cannot return rows the user could not read directly
  • Deny semantics: A user denied read on the target resource yields null (belongsTo/hasOne) or an empty array (hasMany/manyToMany) for that relation; out-of-scope rows are filtered, not just hidden
  • Applies to discovered relations: Auto-discovered (autoRelations) relations use the same enforced loader as explicit ones
  • Unregistered targets: Relations to tables not registered as resources have no scope to enforce (no resolver) — these are an explicit author choice
  • Applies to subscriptions: Relations embedded in subscription events (existing, added, changed) are scope-filtered per subscriber — the subscriber's user is captured at subscribe time, and on every push the target resource's read scope is resolved for that user and AND-ed into the included relation, with the same deny semantics as the read path. A subscriber can never receive related rows it could not read directly. Relations are loaded per subscriber rather than shared across them (cost scales with subscriber count; loads are deduplicated per subscriber within a single push)

Relation-Path (Join) Scopes​

  • Never evaluated by in-memory pass-through: A scope term that traverses a relation (e.g. org.members.userId==<id>) converts to a correlated EXISTS subquery and is enforced as SQL. It is never matched against a plain row in memory — the in-memory matcher throws rather than guess, and every in-memory site handles that by pushing to SQL, conservatively recomputing, or denying. A join scope cannot silently pass-through (leak) or silently deny-all (break)
  • Trusted scopes only: Relation paths are accepted only in developer-authored auth scopes. A relation path in an untrusted user-supplied ?filter= (read, count, aggregate, subscribe, search) is rejected with 400 — including paths through auto-discovered relations — so a client cannot join into other tables to infer their contents
  • Resolution & bounds: Each hop resolves against the resource's resolved relations (explicit ∪ auto-discovered). Unknown relation/column, a cyclic (table-revisiting) path, or a path beyond 5 hops is a FilterParseError. Interpolated values are bound parameters, never string-interpolated, so the join is injection-safe
  • Read/query paths are exact: list, GET /:id, count, aggregate, and batch enforce the join as SQL immediately
  • Subscriptions are eventually-consistent: live per-row matching is skipped for join scopes; the periodic scope recheck (sse.scopeRecheckMs) re-runs the join as SQL and reconciles added/removed. A join subscribe scope therefore requires scopeRecheckMs > 0 (rejected otherwise); catchup on reconnect invalidates so the client refetches under the SQL-enforced scope
  • Search hits are DB-intersected: when the read scope is a join, search post-filtering intersects hit ids against the scope run as SQL rather than matching in memory

Internal Schema & Stores​

  • Logical contract is stable: SessionData (id/userId/createdAt/expiresAt/data) is always exposed in logical keys regardless of the underlying column names; remapping (defineInternalSchema fieldMap) applies only at the SQL persistence boundary — see Internal tables
  • Fail-fast validation: defineInternalSchema validates that every required logical key resolves to a column on the supplied table at construction time, never at query time
  • Defaults are unchanged: with no internalSchema, the built-in tables and the byte-for-byte original migration DDL are used; createCovara({ internalSchema }) is for migration/introspection and never silently rewires a pre-built store
  • Passkeys table: auth_passkeys (id = credential id primary key) is a framework-owned internal table like the others — remappable via defineInternalSchema({ passkeys }), created by migrateInternal, and read column-by-column by DrizzlePasskeyStore; the signature counter is stored as BIGINT on Postgres so the full unsigned 32-bit range fits
  • Migration safety: generated DDL (migrateInternal mode c) covers single-primary-key tables only and throws for compound-PK tables (auth_accounts, auth_verification_tokens) rather than emitting an incorrect schema; managedExternally: true makes migration a no-op
  • KV session store is backend-agnostic: createKVSessionStore works over any KV adapter; its hash field names are a private serialization, never user-facing, and are not remappable
  • Users/files are app-owned: the framework never owns a users table (reached via getUserById) or a files table (supplied to fileResource); the changelog and rate limits are KV/memory-backed, not database tables

Observability Storage​

  • Defaults preserve behavior: the audit log, request/error logs, and metrics are append-only logs backed by an in-memory ring buffer when no KV is configured — identical to before; with a global KV they persist automatically
  • append never throws: a failing storage backend can never break the audited/served action; the in-memory mirror always records the entry even if a KV write fails
  • setAdminAuditSink is write-only: it runs alongside the adapter and is for forwarding only; durable read/query/export requires an ObservabilityLogAdapter
  • Cross-process reads: in KV mode synchronous reads reflect only the local process mirror; authoritative cross-instance reads use the async query()/export() path

Admin Scope Bypass (Admin UI)​

  • Identity re-verification, no secret: The resource layer skips per-resource auth scopes only when the request carries the x-covara-admin-bypass marker and its authenticated user (or forwarded admin apiKey) passes the registered admin predicate. The marker is not a secret and grants nothing on its own
  • Leaked marker is inert: A non-admin presenting the marker is served under normal scope enforcement (fail-closed); the marker value is constant and confers no authority
  • Disabled by default: Bypass is inert unless an admin predicate is registered, which happens only when the admin UI is mounted via createCovara; standalone useResource never bypasses
  • Gated on admin auth: The admin predicate is derived from the UI's security config — locking down the UI's auth locks down bypass
  • Audited: Every bypassed API-explorer request is recorded in the admin audit log (api_explorer_execute)

Admin Impersonation (Admin UI)​

  • Runs as the target, under their scope: A request carrying the x-covara-impersonate: <userId> marker resolves the impersonated user's per-operation scope and attributes writes to them — it does NOT grant allScope()
  • Identity re-verification, no secret: The marker is honored only when the forwarded request's real authenticated user passes the admin predicate (same test as bypass) and a userManager-backed resolver yields the target user; a forged/leaked marker from a non-admin is inert
  • Replaces bypass, never stacks: When impersonation is active the scope layer uses the impersonated user's scope and skips the bypass branch entirely — impersonating an admin yields only that admin's scope, never full bypass (no escalation)
  • Single effective-user swap: A middleware swaps c.get("user") to the impersonated user once, so scope, write attribution, and hooks all observe the same identity; admin-UI routes (/__covara/*) are exempt so admin authorization is unaffected
  • Audited with both ids: Every impersonated action records the real admin id and the impersonated user id (impersonate_execute, data_explorer_list)

Non-Guarantees​

Token Lifetime (What We Don't Promise)​

  • ❌ Minimum lifetime: Tokens may be revoked at any time
  • ❌ Refresh success: Refresh tokens may be revoked or expired
  • ❌ Grace period: No grace period after token expiration

Availability (What We Don't Promise)​

  • ❌ JWKS availability: JWKS endpoint may be temporarily unavailable
  • ❌ Session persistence: Sessions may be cleared (e.g., server restart with memory store)

Threat Model​

In Scope (Protected Against)​

  • Token forgery
  • Token replay (with nonce)
  • Session fixation
  • CSRF (with state parameter)
  • Algorithm confusion attacks
  • JWT injection via none algorithm
  • Mass assignment (via enforced fields.writable)
  • Open redirect via redirect_uri (component-wise validation)
  • PKCE downgrade (plain rejected, required for public clients)
  • Federated id_token forgery (signature + nonce + sub cross-check)
  • Stored-XSS via login_hint (HTML-escaped)
  • Passkey challenge replay, cross-origin / cross-RP assertions, and cloned authenticators detectable by signature-counter regression

Out of Scope (Not Protected Against)​

  • Compromised signing keys (operational security)
  • Client-side token theft (XSS)
  • Phishing attacks
  • Brute force (rate limiting helps but doesn't eliminate)
  • Side-channel attacks

Rate Limiting​

Auth Endpoints​

  • Login: 5 attempts per 15 minutes per IP
  • Token refresh: 10 per minute per user
  • Password reset: 3 per hour per email

Rate Limit Bypass Protection​

  • Header normalization (case-insensitive)
  • IP normalization (IPv4/IPv6)
  • X-Forwarded-For only from trusted proxies

Failure Modes​

Invalid Token​

  • Returns 401 Unauthorized
  • Clear error message (without leaking info)
  • No retry (client must re-authenticate)

JWKS Unavailable​

  • Use cached keys if available
  • Return 503 if no cached keys
  • Automatic retry with backoff

Session Expired​

  • Returns 401 Unauthorized
  • Client should redirect to login
  • Refresh token may still be valid

Test Coverage​

  • tests/invariants/auth-hardening.test.ts - Security edge cases
  • tests/auth.test.ts - Basic authentication
  • tests/auth-routes.test.ts - Auth endpoints
  • tests/oidc/provider.test.ts - OIDC provider
  • tests/auth/passkey.test.ts - Passkey registration/login/management invariants (real ES256/RS256 attestations and assertions)
  • tests/auth/passkey-stores.test.ts - PasskeyStore contract (memory, KV, Drizzle SQLite/Postgres, remapped tables) and single-use challenge stores