Auth and database
How Better Auth is configured, how server code reads the session, roles and impersonation, rate limits, the Drizzle schema and how migrations are run.
Sign-in runs on Better Auth 1.4 with its Drizzle adapter, so users, sessions and teams live in your own Postgres. There is no Supabase Auth and no password sign-in. The schema is one file, lib/db/schema.ts, and changes reach the database through Drizzle migrations.
Setting the integration up is the setup-auth and setup-database skills (Auth, Database). This page is about the code.
Better Auth
The server instance is auth in lib/auth/index.ts, mounted at app/api/auth/[...all]. The browser client is authClient in lib/auth/auth-client.ts, with the same plugins.
Sign-in methods
| Method | Turned on by | Notes |
|---|---|---|
NEXT_PUBLIC_GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Callback <site>/api/auth/callback/google. Google One Tap is added when the client id is set |
|
| GitHub | NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
Callback <site>/api/auth/callback/github |
| Magic link | always on; needs working email | Link valid 5 minutes |
| Email code (OTP) | always on; needs working email | 6 digits, valid 10 minutes |
The login form (components/auth/LoginForm.tsx) shows a provider's button only when its client id is set. Accounts with the same email are linked for Google and GitHub. The lastLoginMethod plugin remembers the last method used.
Plugins
| Plugin | What it adds |
|---|---|
magicLink, emailOTP |
The two email sign-in methods; both send through lib/mail from DEFAULT_ADMIN_EMAIL |
oneTap |
Google One Tap, only when NEXT_PUBLIC_GOOGLE_CLIENT_ID is set |
captcha (Cloudflare Turnstile) |
Only when TURNSTILE_SECRET_KEY is set. Guards the three routes that send mail: /sign-in/magic-link, /email-otp/send-verification-otp, /sign-in/email-otp. OAuth is not gated |
lastLoginMethod |
Records the method of the last sign-in |
admin |
Roles, bans, impersonation, with the access table in lib/auth/admin-access.ts |
organization |
Teams, members, invitations, seat caps (lib/auth/organization.ts) |
nextCookies |
Lets Server Actions set auth cookies; must stay last |
There is no anonymous plugin: /api/auth/sign-in/anonymous does not exist and user.is_anonymous is always false.
Sessions
- Sessions last 30 days and are extended once a day of use.
- The session is cached in a signed cookie for 10 minutes. Within that window a ban or role change is not visible through the cache.
- The session row stores the client IP, read with the same header order as the rate limiter and attribution (
CLIENT_IP_HEADERSinlib/ip.ts).
Hooks
| Hook | What it does |
|---|---|
user.create.before |
Reads the signup blocklist from Redis and refuses the signup (403) when the email matches a prefix, suffix or exact rule, or the IP is listed. Applies to every sign-in method. The admin edits the list from the admin users page (actions/users/signup-blocklist.ts); a Redis failure means an empty list |
user.create.after |
Calls grantSignupRewards (signup-gift gate, then the once-only welcome credits; see Credits); records the signup event for the optional tracker; saves attribution to user_source when NEXT_PUBLIC_USER_SOURCE_TRACKING_ENABLED=true; sends the welcome email. Nothing in it can block the signup |
deleteUser.beforeDelete |
Refuses to delete a user with a live subscription (cancel it first) or a user who owns a team (transfer or delete the team first) |
Attribution: proxy.ts writes a first-touch cookie with UTM fields, the ref/via/aff code, referrer and landing page (lib/tracking/capture.ts); the browser adds screen, language and time zone; the hook turns it into a user_source row with parsed user agent and Cloudflare geo headers. With NEXT_PUBLIC_COOKIE_CONSENT_ENABLED=true, a visitor who declined cookies is not tracked.
Reading the session on the server
lib/auth/server.ts has two channels. Both are deduped per request with React cache().
| Helper | Reads | Use for |
|---|---|---|
getSession() |
the 10-minute cookie cache | personalising a page, ordinary user actions |
getFreshSession() |
the database, bypassing the cache; writes no cookie back | anything that authorises a change to someone else's data; a ban, revocation or demotion applies at once |
isAdmin(), isSuperAdmin() |
the fresh channel | booleans only, no redirect; signed out reads as false |
getWorkspaceContext() |
the session's active organization, re-checking membership | { userId, organizationId }; organizationId is null in the personal workspace or when the user is no longer a member |
import { getSession, isAdmin } from '@/lib/auth/server'
export async function myAction() {
const session = await getSession()
if (!session?.user) return actionResponse.unauthorized()
// …
}
export async function myAdminAction() {
if (!(await isAdmin())) return actionResponse.forbidden()
// …
}
Page redirects belong to components/auth/AuthGuard.tsx, used by the dashboard layout: no session redirects to /login; a role check uses the fresh session. lib/auth/permissions.ts has isAdminUserRole and isSuperAdminUserRole, safe on server and client.
More patterns (client hooks, API routes, sign-out, adding a provider) are in skills/reference/auth.md.
Roles and impersonation
user.role is one of user, admin, superadmin. pnpm create-superadmin <email> (lib/db/scripts/create-superadmin.ts) creates or promotes the owner account.
Permission (lib/auth/admin-access.ts) |
user | admin | superadmin |
|---|---|---|---|
| create, list, get, update, ban, delete users; set role; set password; list, revoke and delete sessions | ✓ | ✓ | |
| impersonate | ✓ |
Some actions add their own checks: changing a role and deleting an account (actions/users/admin.ts) require superadmin.
Impersonation (actions/users/impersonation.ts):
startImpersonationrequires a superadmin (fresh session); the target may not be an admin, a superadmin or banned. Admin accounts can never be borrowed, because the admin plugin lists both roles inadminRoles.- Better Auth swaps the cookie and keeps the superadmin's own session in a signed
admin_sessioncookie. The borrowed session lasts one hour;session.impersonated_byrecords who borrowed it. components/auth/ImpersonationBanner.tsxshows "Viewing as …" with the way back;stopImpersonationends it. Both actions log a warning for the audit trail.- While impersonating you act as that user, including spending their credits.
Teams
The organization plugin (lib/auth/organization.ts):
- The creator becomes
owner. A user may create up to 5 organizations. - Invitations expire after 48 hours and are mailed with
emails/organization-invitation.tsx, linking to/accept-invitation/<id>. - The seat cap follows the team's live subscription: the plan's
seats, or 1 (the owner alone) without one. It is checked on invite, on accept and on add, and a race that overfills a team is undone under an advisory lock. - An organization with a live subscription or any credit history cannot be deleted.
Rate limiting
Two separate mechanisms:
Better Auth's limiter (rateLimit in lib/auth/index.ts) applies only when NODE_ENV=production and NEXT_PUBLIC_RATE_LIMIT_ENABLED=true. Per IP:
| Route | Limit |
|---|---|
| any auth route (default) | 100 per 60 s |
/sign-in/magic-link |
3 per 60 s |
/email-otp/send-verification-otp |
3 per 60 s |
/sign-in/email-otp |
5 per 60 s |
/get-session |
not limited |
With Redis configured the counters are stored there; otherwise Better Auth keeps them in memory.
checkRateLimit(identifier, config) (lib/redis/rate-limit.ts) is for your own actions and routes. Upstash uses a sliding window; self-hosted Redis (ioredis) uses a fixed window that can allow up to twice the quota at a window edge. Without Redis every call is allowed. Presets in RATE_LIMIT_CONFIGS:
| Preset | Limit | Used by |
|---|---|---|
newsletter |
10 per day per IP | actions/newsletter (subscribe and unsubscribe) |
anonymousUpload |
100 per day per IP | actions/r2-resources |
anonymousDownload |
100 per day per IP | actions/r2-resources |
import { checkRateLimit, getClientIPFromHeaders, RATE_LIMIT_CONFIGS } from '@/lib/redis'
const ip = await getClientIPFromHeaders()
if (!(await checkRateLimit(ip, RATE_LIMIT_CONFIGS.newsletter))) {
// over the limit
}
The schema
All tables are in lib/db/schema.ts, in the public schema. Primary keys are uuids. Timestamps are timestamp with time zone. Money columns are integers in cents.
| Area | Tables |
|---|---|
| Auth (Better Auth) | user, session, account, verification |
| Teams (organization plugin) | organization, member (unique per organization and user), invitation |
| Attribution | user_source |
| Billing | orders, subscriptions |
| Credits | credit_balances, organization_credit_balances, credit_transactions |
| Content | posts, tags, post_tags |
| Feedback | feedback, feedback_replies |
Columns worth knowing:
user:role(user_roleenum),banned,ban_reason,ban_expires, and one customer id per provider:stripe_customer_id,creem_customer_id,paypal_payer_id(each unique). Each id has one writer:lib/billing/customer.ts,lib/billing/creem.tsandlib/billing/paypal.ts.session:active_organization_id(null = personal workspace) andimpersonated_by, both without a foreign key.orders:plan_id(a slug fromconfig/pricing.ts, not a foreign key),order_type,status(pending,completed,partially_refunded,refunded,failed), the amount columns in cents (amount_total,amount_subtotal,amount_discount,amount_tax,amount_refunded),provider,provider_order_id,provider_payment_id,provider_subscription_id. Unique on(provider, provider_order_id).subscriptions:plan_id,provider,provider_subscription_id(unique withprovider),status,interval, the period columns, and the yearly drip schedulemonthly_credits,remaining_grants,next_grant_at.credit_transactions: see Credits.
Plans are not in the database. payment_provider is a Postgres enum that mirrors the PaymentProvider type in config/pricing.ts.
Foreign keys and deletion
| Column | On delete |
|---|---|
session, account, member, invitation, user_source, credit_balances → user |
cascade |
orders.user_id, subscriptions.user_id, credit_transactions.user_id → user |
set null (financial history survives, de-identified) |
orders.organization_id → organization |
set null |
subscriptions.organization_id, credit_transactions.organization_id → organization |
restrict |
credit_transactions.order_id → orders |
set null |
posts.author_id, feedback.user_id → user |
set null |
The app refuses to delete users and organizations with live billing before the database is asked (the beforeDelete hooks above).
Migrations
drizzle.config.ts points Drizzle Kit at lib/db/schema.ts and writes SQL to lib/db/migrations/. It reads DATABASE_URL from .env.local.
| Command | Runs | When |
|---|---|---|
pnpm db:migrate |
drizzle-kit migrate |
Apply the migrations the database has not seen. First setup and every deploy that adds one |
pnpm db:generate |
drizzle-kit generate |
After you change schema.ts: writes a new SQL file to review and commit |
pnpm db:push |
drizzle-kit push |
Do not use on a database you migrate: it leaves no migration record, and the next migrate fails on existing tables |
pnpm db:studio |
drizzle-kit studio |
Browse the data |
Agent: after a schema change, runs pnpm db:generate, shows you the new SQL file, and runs pnpm db:migrate against the development database.
You: approve before anything is run against production. To migrate production from your machine:
DATABASE_URL="<production connection string>" ./node_modules/.bin/drizzle-kit migrate
On Supabase, run migrations with the session pooler or direct connection string, not the transaction pooler on port 6543. To keep several products in one database, each in its own Postgres schema, see skills/reference/db-schema-migration.md.
Connection settings
lib/db/index.ts exports db.
- On Cloudflare Workers with a
HYPERDRIVEbinding, each request gets its own client through Hyperdrive (at most 5 connections, prepared statements off). A Worker may not reuse a socket across requests. - Everywhere else one shared client is built by
createDatabaseinlib/db/config.ts, which merges a platform profile with a database profile.
| Platform (detected from env) | Max connections | Prepared statements | Idle timeout |
|---|---|---|---|
Vercel (VERCEL_ENV), Netlify, AWS Lambda |
3 | off | 20 s |
| Long-running server | 30 | on | 300 s |
| Database (detected from the URL) | Settings |
|---|---|
contains supabase |
TLS required, prepared statements off (PgBouncer in transaction mode drops them silently), 30 s statement timeout |
contains neon |
TLS required, prepared statements off |
contains amazonaws.com |
TLS required, keepalive |
contains googleapis.com |
TLS required, keepalive |
| anything else | TLS from the URL's sslmode (disable or allow turn it off, any other value turns it on, ssl=true turns it on); with no setting, off for localhost, 127.0.0.1, ::1, host.docker.internal, db, postgres, and on for every other host |
The last rule means a hosted Postgres that is not one of the four named providers still gets TLS without ?sslmode=require in the URL.
When it fails
| Symptom | Likely cause | Fix |
|---|---|---|
redirect_uri_mismatch on Google sign-in |
The callback URL in Google Cloud does not match NEXT_PUBLIC_SITE_URL |
Add <site>/api/auth/callback/google to the OAuth client |
| Magic link or code never arrives | Email is not set up, or DEFAULT_ADMIN_EMAIL is empty |
Run the setup-email skill; see Email |
| Email sign-in fails with a captcha error | TURNSTILE_SECRET_KEY set without a matching site key or hostname |
Check both Turnstile keys and the widget's hostnames |
| A banned user still loads a page | The page read the cached session | Guard the action with getFreshSession() or isAdmin() |
table … does not exist |
Migrations not applied to this database | pnpm db:migrate with the same DATABASE_URL |
| Connection refused or "SSL required" | TLS setting does not match the host | Add ?sslmode=require (or disable for a local database) to the URL |