Database schema
Every table in drizzle/auth-schema.ts, with columns, types, defaults, indexes and relations, plus the resource specs in drizzle/resources.
Launch Now stores everything in PostgreSQL through Drizzle ORM. The whole schema lives in a single file, drizzle/auth-schema.ts: the Better Auth tables, billing plans, feedback, notifications and the example resources.
Where the schema lives
| File | Role |
|---|---|
drizzle/auth-schema.ts | Every table and relation. The only schema file. |
drizzle/db.ts | Creates the db client (drizzle-orm/node-postgres with a pg pool on DATABASE_URL) and passes the schema, which enables db.query.<table>. |
drizzle.config.ts | Drizzle Kit config: schema: "./drizzle/auth-schema.ts", out: "./drizzle", dialect postgresql. |
drizzle/*.sql, drizzle/meta/ | Generated migrations and snapshots. |
drizzle/migrate.ts | Applies migrations (pnpm db:migrate). |
drizzle/resources/*.json | Resource specs for the CRUD generator. They are inputs, not tables. |
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "./auth-schema"
export const db = drizzle(
new Pool({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 30_000,
}),
{ schema }
)Better Auth reads and writes the same tables through its Drizzle adapter (drizzleAdapter(db, { provider: "pg", schema }) in lib/auth.ts).
To change the schema, edit drizzle/auth-schema.ts, then generate and apply a migration:
pnpm db:generate
pnpm db:migrateSee Scripts for every database command.
Conventions
- Primary keys are
textIDs. Better Auth generates them for its tables; app code usesrandomUUID(). - Column names are
snake_casein SQL andcamelCasein TypeScript (createdAtmaps tocreated_at). timestampcolumns aretimestampwithout time zone.updatedAtcolumns use$onUpdate(() => new Date()), so Drizzle sets them on every update.- Foreign keys to
userandorganizationuseON DELETE CASCADEunless noted.
Authentication tables
These tables are required by Better Auth and its plugins (admin, organization, API key, two-factor, Stripe, database rate limiting). Keep their column names in sync with Better Auth if you upgrade it.
user
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
name | text | Not null |
email | text | Not null, unique |
email_verified | boolean | Not null, default false |
image | text | Avatar URL |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, default now(), set on update |
role | text | Admin plugin. admin for platform admins |
banned | boolean | Admin plugin. Default false |
ban_reason | text | Admin plugin |
ban_expires | timestamp | Admin plugin |
stripe_customer_id | text | Stripe plugin |
two_factor_enabled | boolean | Two-factor plugin. Default false |
onboarding_completed | boolean | Default false. Set by completeOnboarding |
session
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
expires_at | timestamp | Not null |
token | text | Not null, unique |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
ip_address | text | |
user_agent | text | |
user_id | text | Not null, references user.id (cascade) |
impersonated_by | text | Admin plugin. ID of the admin impersonating this user |
active_organization_id | text | Organization plugin. No foreign key |
Index: session_userId_idx on user_id.
account
One row per sign-in method linked to a user (OAuth provider or credential for a password).
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
issuer | text | |
account_id | text | Not null. Provider account ID |
provider_id | text | Not null. For example github, google or credential |
user_id | text | Not null, references user.id (cascade) |
access_token | text | |
refresh_token | text | |
id_token | text | |
access_token_expires_at | timestamp | |
refresh_token_expires_at | timestamp | |
scope | text | |
password | text | Password hash, for credential accounts |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
Indexes: unique account_issuer_accountId_uidx on (issuer, account_id), and account_userId_idx on user_id.
verification
Short-lived values such as email OTP codes.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
identifier | text | Not null |
value | text | Not null |
expires_at | timestamp | Not null |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, default now(), set on update |
Index: verification_identifier_idx on identifier.
rate_limit
Storage for Better Auth's rate limiter (rateLimit.storage: "database" in lib/auth.ts).
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
key | text | Not null, unique |
count | integer | Not null |
last_request | bigint | Not null. Read as a JavaScript number |
apikey
API keys created by the API key plugin.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
config_id | text | Not null, default default |
name | text | |
start | text | |
reference_id | text | Not null. Owner of the key |
prefix | text | |
key | text | Not null |
refill_interval | integer | |
refill_amount | integer | |
last_refill_at | timestamp | |
enabled | boolean | Default true |
rate_limit_enabled | boolean | Default true |
rate_limit_time_window | integer | Default 86400000 (24 hours, in ms) |
rate_limit_max | integer | Default 10 |
request_count | integer | Default 0 |
remaining | integer | |
last_request | timestamp | |
expires_at | timestamp | |
created_at | timestamp | Not null |
updated_at | timestamp | Not null |
permissions | text | |
metadata | text |
Indexes: apikey_configId_idx, apikey_referenceId_idx and apikey_key_idx.
two_factor
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
secret | text | Not null |
backup_codes | text | Not null |
user_id | text | Not null, references user.id (cascade) |
verified | boolean | Default true |
failed_verification_count | integer | Default 0 |
locked_until | timestamp |
Indexes: twoFactor_secret_idx on secret and twoFactor_userId_idx on user_id.
session_log
History of ended sessions, shown in the admin user detail page next to active sessions (getUserById in lib/admin-actions.ts) and used to detect sign-ins from a new device in lib/auth.ts.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key. Same ID as the deleted session |
user_id | text | Not null, references user.id (cascade) |
expires_at | timestamp | Not null |
token | text | Not null |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
ip_address | text | |
user_agent | text | |
impersonated_by | text | |
deleted_at | timestamp |
Index: sessionLog_userId_idx on user_id.
Rows are written by the database, not by app code: migration drizzle/0003_petite_grim_reaper.sql creates a log_session_deleted() function and a session_delete_trigger trigger that copies each session row into session_log before it is deleted.
Organization tables
Created for Better Auth's organization plugin.
organization
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
name | text | Not null |
slug | text | Not null, unique. Used in /orgs/[orgSlug] URLs |
logo | text | Logo URL |
created_at | timestamp | Not null, no default |
metadata | text | |
stripe_customer_id | text | Stripe customer for organization billing |
member
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
organization_id | text | Not null, references organization.id (cascade) |
user_id | text | Not null, references user.id (cascade) |
role | text | Not null, default member. owner, admin or member |
created_at | timestamp | Not null, no default |
Indexes: member_organizationId_idx and member_userId_idx.
invitation
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
organization_id | text | Not null, references organization.id (cascade) |
email | text | Not null |
role | text | Role granted on acceptance |
status | text | Not null, default pending. The app also sets canceled |
expires_at | timestamp | Not null |
created_at | timestamp | Not null, default now() |
inviter_id | text | Not null, references user.id (cascade) |
Indexes: invitation_organizationId_idx and invitation_email_idx.
Billing tables
subscription
Written by the Better Auth Stripe plugin. reference_id is either a user ID or an organization ID, depending on who owns the subscription.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
plan | text | Not null. Plan name, for example pro |
reference_id | text | Not null. User or organization ID. No foreign key |
stripe_customer_id | text | |
stripe_subscription_id | text | |
status | text | Not null, default incomplete. Stripe status such as active, trialing or past_due |
period_start | timestamp | |
period_end | timestamp | |
trial_start | timestamp | |
trial_end | timestamp | |
cancel_at_period_end | boolean | Default false |
cancel_at | timestamp | |
canceled_at | timestamp | |
ended_at | timestamp | |
seats | integer | |
billing_interval | text | |
stripe_schedule_id | text |
plan
The plan catalog, seeded by pnpm db:seed:plans (free, pro, ultra).
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
name | text | Not null, unique. Matches subscription.plan |
display_name | text | Not null, default '' |
description | text | Not null, default '' |
active | boolean | Not null, default true |
sort_order | integer | Not null, default 0 |
limits | json | Not null, default {}. Typed as Record<string, unknown> |
features | json | Not null, default []. Typed as string[] |
monthly_price_cents | integer | Not null, default 0 |
billing_interval | text | Not null, default month |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
Index: plan_sortOrder_idx on sort_order.
Feedback
feedback
Messages sent from the in-app feedback dialog (createFeedback in lib/feedback-actions.ts).
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
user_id | text | Not null, references user.id (cascade) |
user_email | text | Not null. Copied when the feedback is created |
user_name | text | Not null. Copied when the feedback is created |
message | text | Not null |
category | text | |
status | text | Not null, default new. new, read, acknowledged or resolved |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
Indexes: feedback_userId_idx and feedback_status_idx.
Notification tables
See Notifications for how these tables are used.
notification
One row per notification. The audience column decides who sees it:
user: the user inuser_id.organization: members oforganization_id, optionally only those whose role is inroles.admins: platform admins (user.role = 'admin').all: every user (broadcasts).
Reads and archives are stored in notification_receipt, so a broadcast stays a single row.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
type | text | Not null. Event type, for example announcement or org.role_changed |
category | text | Not null. organization, billing, security, admin or announcements |
audience | text | Not null. user, organization, admins or all |
user_id | text | References user.id (cascade) |
organization_id | text | References organization.id (cascade) |
roles | text[] | Organization roles to target |
title | text | Not null |
body | text | |
link | text | |
actor_id | text | References user.id (ON DELETE SET NULL). Who triggered it |
created_at | timestamp | Not null, default now() |
Indexes: notification_userId_idx on (user_id, created_at), notification_organizationId_idx on (organization_id, created_at) and notification_audience_idx on (audience, created_at).
notification_receipt
Per-user read and archive state.
| Column | Type | Notes |
|---|---|---|
notification_id | text | Not null, references notification.id (cascade) |
user_id | text | Not null, references user.id (cascade) |
read_at | timestamp | |
archived_at | timestamp |
Primary key: (notification_id, user_id). Index: notification_receipt_userId_idx.
notification_preference
Per-user, per-category delivery settings. A missing row means the category defaults from NOTIFICATION_CATEGORIES in lib/notifications/events.ts apply.
| Column | Type | Notes |
|---|---|---|
user_id | text | Not null, references user.id (cascade) |
category | text | Not null |
in_app | boolean | Not null |
email | boolean | Not null |
Primary key: (user_id, category).
Example resource tables
These tables back the example CRUD pages. They sit between @reset-remove:start and @reset-remove:end markers, so pnpm reset-project removes them from the schema. Run pnpm db:generate and pnpm db:migrate afterwards to drop them.
categories
User-scoped example.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
user_id | text | Not null, references user.id (cascade) |
name | text | Not null |
description | text | |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
Index: categories_userId_idx.
projects
User-scoped example.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
user_id | text | Not null, references user.id (cascade) |
name | text | Not null |
status | text | Not null, default planning |
budget | integer | |
due_date | text | Stored as a string |
category_id | text | References categories.id (ON DELETE SET NULL) |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, set on update |
Indexes: projects_userId_idx and projects_categoryId_idx.
org_projects
Organization-scoped example, generated from drizzle/resources/org-projects.json by pnpm resource:generate.
| Column | Type | Notes |
|---|---|---|
id | text | Primary key, defaults to randomUUID() in Drizzle |
user_id | text | Not null, references user.id (cascade). The creator |
organization_id | text | Not null, references organization.id (cascade) |
name | text | Not null |
description | text | |
status | text | Default planned |
due_date | date | Read as a string |
created_at | timestamp | Not null, default now() |
updated_at | timestamp | Not null, default now(), set on update |
Indexes: org_projects_userId_idx and org_projects_organizationId_idx.
Relations
Drizzle relations (declared with relations() in drizzle/auth-schema.ts) power the with option of db.query. They do not change the SQL schema.
| Table | Relations |
|---|---|
user | many sessions, accounts, members, invitations, twoFactors, feedbacks, categories, projects, sessionLogs |
session | one user |
account | one user |
organization | many members, invitations |
member | one organization, one user |
invitation | one organization, one user (the inviter, via inviter_id) |
twoFactor | one user |
feedback | one user |
categories | one user, many projects |
projects | one user, one category |
sessionLog | one user |
orgProjects | one user, one organization |
The subscription, plan, apikey, verification, rate_limit and notification tables have no Drizzle relations. Query them with explicit joins or filters.
const users = await db.query.user.findMany({
columns: safeUserColumns,
with: {
sessions: {
columns: { id: true, createdAt: true, updatedAt: true, expiresAt: true },
orderBy: desc(session.updatedAt),
limit: 1,
},
},
orderBy: desc(user.createdAt),
})Resource specs
drizzle/resources/ does not contain tables. It holds JSON specs for the resource-kit CRUD generator, plus a README.md that documents every option. The generator turns a spec into a table appended to drizzle/auth-schema.ts, server actions, pages and a manifest entry in lib/resource-kit/manifest.json.
{
"name": "orgProject",
"title": "Projects",
"scope": "organization",
"icon": "FolderKanban",
"labelField": "name",
"fields": [
{ "name": "name", "type": "text", "required": true, "searchable": true },
{ "name": "description", "type": "textarea" },
{
"name": "status",
"type": "select",
"options": ["planned", "active", "paused", "done"],
"defaultValue": "planned"
},
{ "name": "dueDate", "type": "date" }
]
}This spec produces the org_projects table above: the camelCase name becomes a plural snake_case table name, each field becomes a column, and the generator adds id, user_id, created_at and updated_at. The organization scope adds organization_id with a cascading foreign key and an index.
To add your own table from a spec:
pnpm resource:generate --spec drizzle/resources/your-resource.json
pnpm db:generate
pnpm db:migrateSee pnpm resource:generate for the flags and field types.