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.
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_tokensub is cross-checked against the userinfosub
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_tokenat_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
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
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
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
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
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() or omit the operation)
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
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)
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
Logical contract is stable: SessionData (id/userId/createdAt/expiresAt/data) is always exposed in logical keys regardless of the underlying column names; remapping (defineInternalSchemafieldMap) 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
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
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
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)
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)