ShipAgent

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
Google 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_HEADERS in lib/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):

  • startImpersonation requires 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 in adminRoles.
  • Better Auth swaps the cookie and keeps the superadmin's own session in a signed admin_session cookie. The borrowed session lasts one hour; session.impersonated_by records who borrowed it.
  • components/auth/ImpersonationBanner.tsx shows "Viewing as …" with the way back; stopImpersonation ends 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_role enum), 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.ts and lib/billing/paypal.ts.
  • session: active_organization_id (null = personal workspace) and impersonated_by, both without a foreign key.
  • orders: plan_id (a slug from config/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 with provider), status, interval, the period columns, and the yearly drip schedule monthly_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 HYPERDRIVE binding, 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 createDatabase in lib/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