Auth model
Auth is provided by better-auth with
the organization, apiKey, and bearer plugins. Three modes
resolve to the same (user, organizationId) on the request context.
The three modes
Section titled “The three modes”- Cookie session. Set by
POST /api/auth/sign-in/email. Browser UIs. - Bearer session token.
Authorization: Bearer <session_token>. Same identity as cookie path; lets non-browser clients (mobile, scripts) carry a session without a cookie jar. - API key.
Authorization: Bearer thd_…ORx-api-key: thd_…. Identity is the key, not a person. Configured withreferences: "organization"so a verified key resolves to its org in one call (no metadata join).
The middleware contract
Section titled “The middleware contract”After the auth guard passes, every route handler reads:
c.get("user"); // { id, email }c.get("organizationId"); // stringc.get("authMode"); // "session" | "api-key"c.get("apiKeyId"); // string | undefined (only when api-key)Scoping rules
Section titled “Scoping rules”| Resource | Scope | Cross-org access |
|---|---|---|
| Workflow | organization_id | Returns 404, not 403 — we don’t reveal existence. |
| Schedule | organization_id | Same. |
| Run | Inherits from workflow | Same. |
| API key | referenceId = organizationId | A key works only for its org. |
| Webhook route | Programmatic at boot | No HTTP surface to register. |
Sessions vs keys
Section titled “Sessions vs keys”| Session | API key | |
|---|---|---|
| Tied to | A user | The org |
| Lifetime | Session expiry (sliding) | Until revoked |
| Used by | Browser UI, mobile apps | LLM orchestrators, CI, server-to-server |
| Active org | session.activeOrganizationId | Key’s referenceId |
| Revocation | Sign-out (this device) / password reset (all) | DELETE /api/auth/api-key/:id |
When an employee leaves, you don’t scramble through their personal keys — service tokens stay because they belong to the organization. That separation is deliberate.
Auto-org on signup
Section titled “Auto-org on signup”databaseHooks.user.create.after inserts a personal organization +
membership for every new user. The org plugin’s
setActiveOrganizationOnSessionCreate (default true) auto-selects
the only membership when the session has no active org. Result: a
freshly-signed-up user can hit any protected route immediately — no
explicit organization/create + set-active orchestration needed.
Fail-closed
Section titled “Fail-closed”- An empty
apikeytable = no programmatic request authorizes. - A user with no organizations = 401
no_active_organization. - Any path NOT under
/api/auth/*and NOT inopenPaths(just/healthand/api/bootstrap) requires resolved identity. THODARE_BOOTSTRAP=1only opens/api/bootstrapif the user table is empty AND the env flag is set. Misconfig → 404.
Rate limit
Section titled “Rate limit”Per-(organizationId, principal) token bucket. principal is the API
key id when authenticating via key, or the user id when via session.
Default 60 req/min. Bucket per-pair means one tenant cannot starve
another, and one user’s session cannot starve another’s keys.
Implementation
Section titled “Implementation”packages/api/src/auth.ts +
middleware/session.ts.
- Issue + revoke API keys — practical operations.
- Bootstrap a fresh deployment — the cold-start flow.