Skip to content

Auth

Canonical references for the auth surface — the in-repo files are the source of truth, this page summarises the constraints those files encode.

How the operator signs in, and the protections around it, are explained for operators in Sign-in and security. This page is the developer map: where each guarantee lives and why it is built that way.

  • apps/api/src/auth.ts — Better Auth factory. Password sign-in (scrypt, Better Auth 1.7's hash; 12–256 characters, matching SignUpRequest) and single sign-on through genericOAuth, each switched by env. No SMTP, no in-app second factor: multi-factor authentication is the identity provider's to enforce, and the app does not check it. Better Auth's own rate limiter is off; the limits in apps/api/src/auth/sign-in-protection.ts replace it, applied by the app's auth routes and the gateway, including the OAuth provider's endpoints. JWKS is not limited, so agents can always verify tokens.
  • apps/api/src/auth/gateway.ts — default-deny allow-list in front of auth.handler. Only the single sign-on callback and (with MCP on) the OAuth authorize, consent, token, revoke and JWKS endpoints pass through (AUTH_PASSTHROUGH); every other Better Auth endpoint answers 404 and is recorded. Sign-in, sign-up, sign-out and session management are the app's own routes in apps/api/src/routes/auth.ts, which call Better Auth server-side through auth.api. apps/api/__tests__/auth/gateway-topology.test.ts partitions every auth.api endpoint into allowed and denied, so a Better Auth upgrade that adds one fails the suite until it is classified. Session tokens are stripped from response bodies; only Set-Cookie carries them.
  • Single operator, at every layer. The onboarding route, a user.create.before hook, single sign-on's disableImplicitSignUp, and a unique index on "user" (migration 0097) each refuse a second operator.
  • Single sign-on identity is the issuer plus the subject (accountSubject), because sub is unique only within one issuer. Implicit linking by email is off; linking is an explicit, re-authenticated action. The provider's access, refresh and ID tokens are nulled before the account row is written. The email claim is kept in account.providerEmail (written when the identity is created and on every single sign-on sign-in) for the Security page's "Linked as" label only; nothing matches on it. Boot checks discovery with a timeout (apps/api/src/auth/boot.ts) and starts without single sign-on when the provider is unreachable, because Better Auth's own discovery fetch has no timeout and every request waits on it.
  • apps/api/src/auth/rate-limit.ts / sign-in-protection.ts — GCRA in one Redis Lua call per decision: one small key per subject, exact spacing, no window-edge burst, and a precise Retry-After. It is a limiter, not a lock (no owner, no release). If Redis fails, a strict in-process limiter takes over and an alert fires; it never answers 503. Limits are read from the auth_security_settings row (migration 0098), whose contract (packages/contracts/src/auth-security-settings.ts) bounds each value so a stolen session can tighten protection but never disable it.
  • Security epoch. auth_security_settings.security_epoch is stamped on each session and known-device cookie. Sign-out-everywhere, a password change, the reset command and restore raise it (restore past the epoch that was live before it, not just the dump's), which invalidates every session, known device and open WebSocket at once (a password change re-stamps the browser that made it) (apps/api/src/ws/session-watch.ts revalidates sockets every 60 s and on the auth:session-revoked pub/sub message). Sessions also end at an absolute lifetime measured from createdAt, since Better Auth's sliding updateAge alone would extend them forever. When the settings read fails, the lifetime and idle limits are not enforced until a read succeeds (the strict fallback would sign out sessions the operator's settings allow); the epoch check still applies.
  • Agent access tokens are JWTs Better Auth never stores, so deleting token rows does not revoke them. repo.authIdentity.revokeAgentAccess deletes the grants and stamps agent_access_not_before; apps/api/src/routes/mcp.ts refuses any token issued at or before it. Every revocation path goes through that one function.
  • apps/api/src/auth/security-events.ts — the recorder. Every event in SECURITY_EVENT_CATALOG (packages/contracts/src/security-events.ts) goes to four independent sinks (log, metrics, an audit_logs row, a notification job); it never throws, and a failing sink is counted in auth_event_sink_failures_total. Floods of identical events are grouped into one row per five minutes with a count.
  • apps/api/scripts/reset-password.ts — offline recovery, bundled into the image as /app/dist/reset-password.js. It hashes with better-auth/crypto and never constructs an auth instance, so a hung identity provider cannot block it.
  • apps/api/src/routes/auth.ts — onboarding, session, password, session-management, agent-access, single sign-on link/unlink, security settings and security activity routes. Anything that weakens or moves a credential requires re-authentication (apps/api/src/auth/reauthentication.ts), including POST /api/restore, whose dump replaces the password hash, the single sign-on identity and the security settings; its form carries the proof as a JSON reauthentication part. Unlinking single sign-on also ends every other session that single sign-on created. While PASSWORD_SIGN_IN_ENABLED is off, the password is refused as re-authentication and /change-password answers 404, because Better Auth's own password check ignores that switch.
  • apps/api/src/routes/account-settings.ts — GET/PATCH /account/settings for the master user's account-global display preferences. Today this carries timezone (a validated IANA zone, default UTC) the web UI applies to every rendered timestamp; the value persists on users.timezone.
  • packages/db/migrations/0007_better_auth.sql — initial Better Auth tables.
  • packages/db/migrations/0087_better_auth_account_issuer.sql — Better Auth 1.7 account identity migration.
  • packages/db/src/schema/better-auth.ts — drizzle schema for the same.
  • packages/db/migrations/0095_oauth_provider_tables.sql + packages/db/src/schema/better-auth-oauth.ts — the OAuth 2.1 authorization-server tables, created only for the MCP control plane. Registered with Better Auth only while MCP_ENABLED is on, so an operator who never opted in has no OAuth surface at all. See MCP control plane.
  • apps/api/src/middleware/cors.ts / apps/api/src/routes/ws.ts — the WEB_ORIGIN allowlist (a comma-separated list of exact scheme://host:port origins) gates CORS, Better Auth trustedOrigins (CSRF), and the WebSocket upgrade. Every origin is matched exactly; a * wildcard is rejected at env-parse time because credentialed CORS forbids it. Add a LAN origin to reach the dev server from another device.

Threat model

The system ships without encryption-at-rest. Binance API keys, notifier secrets, and AI-assist provider credentials (ai_provider_config — the Anthropic Console key / OAuth token and any OpenAI-compatible endpoint key, a spend-capable credential) sit plaintext in Postgres; the mitigation is operator-side and twofold: (1) create the Binance API key without the Enable Withdrawals permission — the bot only reads market data and places spot orders, so a leaked key cannot move funds off the exchange — and (2) IP-allowlist the key at the Binance console so a stolen DB dump cannot place orders without also matching the operator's egress IP. The create wizard and the API-key replace form surface both via the shared ApiKeyGuidance component. The REST client also attaches X-MBX-APIKEY only to the calls it signs, so unsigned market-data reads carry no credential past any proxy or log between the worker and Binance. Step-up / TOTP was deliberately dropped because the system runs single-account on a self-hosted VM — adding a second factor without an email channel for recovery would lock the operator out on phone loss with no fallback. A second factor is available through single sign-on instead, where the identity provider owns its recovery.

action_logs rows (msg + ctx) are client-readable at every level, so worker writers MUST keep secrets — API keys, signed URLs, raw credentials — out of action_logs msg/ctx. Three surfaces read them: GET /profiles/:id/action-logs (the activity feed, warn+error only), GET /profiles/:id/logs and its /logs/export (any level, no default level narrowing), and GET /profiles/:id/tick-trace, which returns the raw Redis audit entries verbatim. Deep capture widens this further: while armed, the whole audit payload of every tick is copied into ctx unredacted. Nothing filters these — the rule is that nothing credential-equivalent enters an audit payload or an action_logs row in the first place, because a debug view whose job is showing what the tick saw cannot also be the place a secret is scrubbed.

The process log stream is scrubbed; action_logs is not. Because the credentials above sit plaintext in Postgres, the statement that writes one binds it as a parameter — and drizzle raises a failed statement as an error carrying those bind values three times over: the params array, the message its template inlines them into, and the stack that opens with that message. A logger's default error serializer copies all three, so one failed api_keys insert would write a live Binance secret into the process log, where it outlives the request, the process, and any key rotation. Both loggers (apps/api/src/middleware/logger.ts, apps/worker/src/boot/builders/primitives.ts) therefore run pino's serializer and then scrubDrizzleParams from @app/core/logger, which replaces the bind list wherever it appears — at any nesting depth, through aggregateErrors, and in the chained stack a wrapped cause contributes.

Be precise about what that buys, because it is easy to over-read. It is a backstop for one known error shape, the driver's, not a general redactor: it recognises a params value by its sibling query, and it knows where drizzle puts the bind list in a message. The same known-shape backstop also covers exhausted worker jobs: apps/worker/src/queues/queue-set.ts scrubs the DLQ errorMessage and optional stack before the DLQ Queue.add persists the entry, while apps/worker/src/queues/dlq-watcher.ts and apps/worker/src/boot/builders/notifiers.ts publish and notify from that already-scrubbed errorMessage; this does not make the DLQ path a general payload redactor. It does nothing for a credential a caller passes to a log line itself, and it is deliberately not extended to cover that — a scrubber broad enough to catch arbitrary shapes would be a filter nobody could reason about. The action_logs rule above therefore still governs everything else, unchanged and unweakened: nothing credential-equivalent may enter an audit payload or an action_logs row in the first place. Before adding a route that reads or writes a credential, assume the log stream protects you from the driver's error and from nothing else.

What a backup dump does and does not carry for sign-in. It carries everything the Postgres exception already covers (Binance keys, notifier and AI-provider secrets), which is why a backup download raises a security alert. It does not, on its own, let anyone sign in or act as an agent: MCP access tokens are JWTs Better Auth never stores; refresh tokens are stored hashed (@better-auth/oauth-provider defaults storeTokens to "hashed"); the agent signing key in jwks is encrypted with AUTH_SECRET; and a session row's token is useless without a cookie signed by AUTH_SECRET. All four depend on AUTH_SECRET, which lives in the environment and never in the dump, so a leaked dump plus a leaked environment is the combination to treat as full compromise: rotate AUTH_SECRET, empty jwks, and rotate the Binance key. The backup route stays denied to every MCP tool.

Client-IP trust boundary. The sign-in limits, the request-flood limiter, sessions and the audit trail all derive the client IP from the rightmost X-Forwarded-For hop via clientIpFromHeaders (apps/api/src/auth/client-address.ts), falling back to X-Real-IP then the literal 'unknown'. This trusts exactly one reverse proxy that appends the real client address to the right of the chain (the bundled nginx $proxy_add_x_forwarded_for config does this). The API therefore MUST run behind exactly one such proxy: expose it directly and the leftmost-to-rightmost chain is fully client-controlled, re-opening per-IP throttle bypass and audit-IP spoofing; front it with two or more proxies and the rightmost hop is an internal proxy, collapsing all clients into one bucket. A request with no forwarded headers records 'unknown' (not SQL NULL) in audit_logs.ip.

Live demo (public, no-login, testnet deployment)

LIVE_DEMO (env, read by both the api and the worker; parsed strictly — only 1/true enable it, default off) turns a separate deployment into a public sandbox. It is never the operator's real instance, which always requires login.

  • No login. sessionResolver (apps/api/src/middleware/auth.ts) injects the boot-resolved sole operator id (repo.users.findSingleId, resolved once in di.ts boot) for every request with no Better Auth session. A real session still wins. Zero-user cold start injects nothing, but /sign-up is demo-locked, so a demo box is seeded rather than onboarded. The login screen never appears (it is only shown reactively by the 401 interceptor).
  • Boot refuses a live key. assertLiveDemoInvariant (apps/api/src/di.ts; mirrored in the worker boot) throws when the flag is on and any account is binance_mode='live'. The box can only ever hold testnet keys, so "no sensitive info exposed" holds by construction.
  • Locked routes. requireNotDemo (apps/api/src/middleware/require-not-demo.ts, reads di.env.LIVE_DEMO at request time) returns 403 for API-key management; backup, backup-config, and restore; AI-provider management and testing; notifier configuration and testing; ops-notify; account creation, rename, and delete (rename defaces the box's only account and delete cascades its profiles away until the nightly restore); sign-up, sign-in, sign-out, single sign-on, password, session, agent-access, security-settings and security-activity routes; retention changes; starting a diagnosis run; fee reconciliation; trade-archive backfill; and both backtest-advisor writes (starting a variant, and persisting a manually pasted reply). The advisor is the one locked surface that spends money outside Binance — a variant bills the operator's stored AI-provider credential — so it is disabled outright rather than capped; its reads (GET .../advisor, GET .../advisor/manual/prompt) stay open, so suggestions already saved to a run remain legible in the demo. Fee reconciliation and trade-archive backfill are weighted Binance myTrades pulls whose job ids carry a timestamp, so repeat clicks never dedup and an anonymous visitor could hold the operator's request budget down from a button — the archive one worst, since "Recover all" fires one per symbol at once. Interactive trading remains available against Binance testnet. apps/api/__tests__/routes/live-demo-guard-topology.test.ts checks the exact mounted guard set and independently detects an unguarded sensitive route. apps/api/__tests__/routes/live-demo-guard.test.ts preserves request-level 403 samples.
  • Notifier suppression (worker). Under the flag both notifier fan-outs (createNotifyEvent, createAccountNotifyEvent) are total no-ops — no dispatch, DB gate unread — so a seed snapshot's real webhooks can never leak.
  • Web. The onboarding-status response carries demoMode; the SPA renders a persistent "Live demo" banner and hides every entry point into a locked route. Each navigation destination declares its own answer through a REQUIRED demoHidden field (apps/web/src/shared/lib/demo-visibility.ts), and every nav surface filters through visibleInDemo rather than restating paths — so the compiler rejects a new REGISTRY entry that has not been considered. A link hand-placed in a component sits outside that guarantee and is hidden by an explicit check at its own site. Currently hidden: Settings, backup/restore, account settings, New account, Manage account, the per-profile Notifications section, both "configure API key" call-to-actions, the start-investigation control, Reconcile fees, and every advisor control on the backtest panel (the five variant buttons, "Run it myself", the paste-a-reply panel, and Regenerate) — the panel itself stays, showing saved suggestions plus a note that asking the AI is off because it spends the operator's own AI credit. Because the hidden variant grid was also the only way to switch which saved slot is displayed, the demo renders a read-only switcher in its place whenever more than one slot holds a saved row (showVariant is local state and issues no request), so a newest-first error row cannot bury the good suggestions behind it. A single saved row needs no switcher — it is already the row on screen. Trade-archive backfill is reached from History, which stays visible, so that one is guarded server-side only. The API guard is the enforcement; the nav hide is defence-in-depth, so a hand-typed URL still 403s.

The ops half (testnet golden-snapshot seeding, the separate deployment, the nightly reset that re-restores the snapshot directly rather than via the locked /restore route, and public-endpoint rate limiting) is operator-side. Compose override: deploy/compose/docker-compose.demo.yml.