Skip to content

Auth and sessions

Sign-in is Better Auth, configured in apps/api/src/auth/auth.ts. Your controllers never deal with passwords or tokens: they receive a signed-in user or a 401.

Method What proves the address
Email and password A verification email, which must be clicked first
Magic link The link itself, valid for 5 minutes and used once
Google, GitHub The provider. Shown only when both of its values are set

Signing in with a provider whose email matches an existing account adds that provider to the account instead of making a second one.

A password account cannot sign in until its email is verified. Signing up, and every sign-in attempt before verifying, sends the verification email again. Verifying signs the person in. The welcome email goes out once per person, at whichever moment their address is first known to be theirs.

A password reset link lasts an hour, and using it signs the person out everywhere else. Changing email takes two clicks: one in the current inbox to approve, one in the new inbox to prove it.

A session is a row in the sessions table and a cookie that points at it. It lasts 7 days, and each request extends it, at most once a day, so someone who keeps using the app stays signed in. The cookie is httpOnly and SameSite=Lax, and Secure whenever WEB_URL starts with https://.

Because the browser, the web app and the API share one address, in development through the Vite proxy and in production through Caddy, the cookie is first-party. Requests from any origin other than WEB_URL or those in CORS_ORIGINS are refused.

The mobile app signs in through the same endpoints. It has no cookie jar, so it keeps the session cookie in the device’s secure storage and sends it itself, and it has no origin, so it names its URL scheme instead: sign-in is accepted from the scheme in MOBILE_SCHEME and refused from any other. See Mobile app.

Everything under /api/auth: sign-up, sign-in, sessions, email changes, account deletion, workspaces, members and invitations. It is mounted in apps/api/src/app.setup.ts ahead of NestJS, because it reads the raw request itself. Do not write controllers that repeat these endpoints.

It also owns the tables in apps/api/src/db/schema/auth.ts. You may add columns to them; never rename or remove the existing ones, because Better Auth reads them by name.

AuthGuard runs on every route. It looks up the session from the cookie and answers 401 Sign in to continue when there is none, so every route needs a session unless you mark it @Public(). On the others, read the person with @CurrentUser() user: CurrentUser.

The guard also keeps admins in step with ADMIN_EMAILS: an address on the list, once verified, is an admin, and removing it takes effect on that person’s next request. It is also checked at sign-in, so Better Auth’s own admin endpoints see the right role at once. @AdminOnly() restricts a route to them, and refuses an admin who is signed in as someone else. See Admin panel.

Programs call the API with a key instead of a session: Authorization: Bearer sk_…. Admins create and revoke them under Settings → API keys; with personal accounts that is everyone, for their own account.

  • A key belongs to the workspace, not to the person who made it, and keeps working after they leave. Revoke the keys someone knew when they go.
  • It has a role of its own, admin or member, and no key is an owner, so none reaches billing. With personal accounts the role is not shown, and keys are admin.
  • It reaches its own workspace’s @WorkspaceScoped() routes and nothing else, under the same @RequireRole() and @RequireFeature() checks as a person. Another workspace answers 404; any other route, such as /api/me, 403.
  • A route that reads @CurrentUser() is for people: keys get 403 there, before it runs. For a route that keys may call too, read @CurrentCaller(), which is the person or the key, as the assistant does. Mark a route @NoApiKeys() to refuse keys without reading the user; managing keys is marked so.
  • Only a SHA-256 hash is stored, with the first characters to recognise the key by. The whole key is shown once, when it is created.
  • Each key may make 120 requests a minute (DEFAULT_RATE_LIMIT in apps/api/src/api-keys/api-keys.service.ts), then gets 429 with Retry-After. As with sign-in, it is counted in Redis, so it holds across every copy of the API.
  • lastUsedAt is written at most once a minute per key.
  • Each request with a key counts as one of the apiCalls meter, on plans that list it under usage: see Charge for usage.

Creating and revoking keys is recorded in the audit log.

Before an account is deleted, WorkspacesService.releaseBeforeDeleting decides what happens to the person’s workspaces:

  • A workspace where they are the only member has its subscription ended and is deleted, with everything in it.
  • If they are the only owner of a workspace that has other members, the deletion is refused until they make someone else an owner or delete the workspace.
  • Workspaces they merely belong to lose a member.

If your feature stores something outside the database, such as files, clean it up there too.

In production Better Auth limits requests by address: 3 attempts in 10 seconds for signing in, signing up and changing email or password; 3 a minute for reset and verification emails; 100 in 10 seconds for the rest. The counts are kept in Redis, so they hold however many copies of the API run, and across restarts. Tests and development have no limits.

What happens to workspaces through these endpoints (created, renamed, invitations, members joining, leaving, changing role) is written to the audit log by a hook in apps/api/src/auth/auth.audit.ts. See Record activity.