go-auth

Schemas

The tables go-auth creates, the differences between drivers, and the three ways to generate or apply the schema.

Schemas

go-auth embeds one complete desired auth schema and a versioned migration catalog per driver. Auth migrations run separately from application and ORM migrations. New never executes DDL. Use the matching CLI before starting auth services. Version 0.5.2 targets revision 3 on all drivers. The additive upgrade preserves existing token rows and adds empty workflow context/payload values; PostgreSQL also extends the token-type check for optional 2FA setting confirmations.

The column tables below describe the PostgreSQL schema. Names are fixed on SQLite and MySQL; types, collations, constraints and indexes differ (see Driver differences). The journal table goauth_migrations is reserved in addition to the fifteen auth tables documented here.

Applying and exporting the schema

Run these commands from the application's Go module, where go-auth is already pinned in go.mod. From this repository's source checkout, use go run ./cmd/goauth instead.

go run github.com/nazimdjebloun/go-auth/cmd/goauth migrate \
  --driver postgres --dsn "$DATABASE_URL"

An unused auth namespace receives the published baseline. A tracked installation receives pending migrations in numeric order. Completed SQL is not replayed; history and the previous schema are checked before continuing. An untracked installation must use explicit adoption.

All schema and migration commands accept postgres/pg, sqlite/sqlite3, and mysql. The CLI registers all three drivers. The migration catalog begins at revision 000001; adoption currently supports only its complete schema, not arbitrary earlier release layouts. See Migrations for history, recovery, ownership, timeouts and deployment procedures.

PostgreSQL requires pgcrypto and pg_trgm. The baseline issues CREATE EXTENSION IF NOT EXISTS for both; provision them beforehand if deployment credentials cannot create extensions. They may be shared with the application and are never treated as auth-only tables.

The complete schema includes application authorization tables (app_roles, app_permissions, app_role_permissions, app_authorization_state) even when the optional service is disabled. The app_permissions_namespace_owner CHECK requires is_system = (substr(permission_key, 1, 11) = 'goauth.app.'). Migration execution creates no roles or permission records; initialize them through the application permission workflow.

Organization authorization tables

The canonical schema includes these tables even when org permissions are disabled. Migration revision 2 creates their structure without inserting roles or grants.

TablePurpose
org_authorization_stateOne revision row per initialized organization; references organizations with cascade deletion.
org_rolesOrg-local IDs/slugs and protected owner, admin, member identities; enabled state and optimistic revision.
org_permissionsOrg-local exact keys, metadata, enabled state, system ownership and revision.
org_role_permissionsExplicit grants; both role and permission references include org_id.
org_member_rolesAt most one explicit role per (org_id,user_id) membership, plus assignment revision.
org_invite_rolesProposed role and observed role revision per (org_id,invite_id) invitation.

Role and permission candidate keys (org_id,id) enforce same-org links. A unique organization_invites(org_id,id) index supports its composite invitation foreign key. Assignment links restrict role deletion; services reject roles referenced by members or invitations. Membership and invitation deletion cascade their links. Organization deletion through the service removes assignment links before its existing cascades. Role/permission deletion cascades grant links at SQL level; services refuse referenced business-definition deletion and explicitly clean up library grants during owner-authorized removal.

org_permissions_namespace_owner requires is_system = (substr(permission_key, 1, 11) = 'goauth.org.'). Built-in role system keys are constrained to owner, admin, member, unique per organization. Keys/slugs use binary collation on MySQL. See the complete embedded canonical SQL and the org permission workflow.

The tables

Twenty-one tables. Most foreign keys to users are ON DELETE CASCADE, so deleting a user takes their sessions, tokens, linked OAuth accounts, and org memberships with it. sessions.active_org_id and organizations.created_by use ON DELETE SET NULL: deleting an org clears it from sessions pointing at it, and an org outlives its creator's account. invites.created_by declares no delete action. app_role_permissions.granted_by uses ON DELETE SET NULL. users.app_role_id and app-role grant links to permission definitions use ON DELETE RESTRICT, so referenced roles and definitions cannot be deleted.

recovery_requests has no account foreign key: the same row is written for known and unknown addresses, and the worker discards it if no eligible account exists when processing. Audit records and delivery obligations also remain independent of account deletion.

Passwords are salted KDF hashes (bcrypt by default, or the configured hasher), optionally peppered before hashing. High-entropy session, refresh, reset, OAuth state, and invite tokens use SHA-256 hashes. User-entered verification, 2FA, set-password, and deletion codes use HMAC-SHA256 with the OTP pepper. Raw tokens and codes are delivered once through the relevant transport or email. The OAuth PKCE code_verifier is stored in the short-lived state row for the later token exchange; provider access and refresh tokens are encrypted rather than hashed.

The summaries describe PostgreSQL types and constraints. Each table includes the exact DDL the library applies on each driver, indexes included. Table sections follow PostgreSQL dependency order; apply its extension statements first, as described above.

recovery_requests

Durable public password-reset and verification-resend requests. The public request writes only the supplied normalized address and operation, before any account lookup. No raw reset token or verification code is stored here.

ColumnTypeNotes
idTEXTApplication-generated request ID
kind / emailTEXTpassword_reset or verification, and the normalized address
attemptsINTEGERIncremented on each claim, including claims interrupted by a crash
available_atBIGINTNext eligible time, UTC Unix seconds
lease_until / claim_ownerBIGINT / TEXTTwo-minute lease and unique owner; stale workers cannot acknowledge a reclaimed job
dead_letteredINTEGER1 when processing fails after the five-attempt budget is exhausted
created_atBIGINTUTC Unix seconds; basis for eligibility and retention

Successful processing deletes the row. Failures retry after 10, 20, 40, and 80 seconds; delivery is at least once, so a crash after sending can cause another email and credential regeneration. Requests become ineligible after 24 hours. The minute-based cleanup deletes expired pending requests and dead letters older than seven days from creation, while preserving active leases.

CREATE TABLE IF NOT EXISTS recovery_requests (
    id TEXT PRIMARY KEY,
    kind TEXT NOT NULL,
    email TEXT NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    available_at BIGINT NOT NULL,
    lease_until BIGINT NOT NULL DEFAULT 0,
    claim_owner TEXT NOT NULL DEFAULT '',
    dead_lettered INTEGER NOT NULL DEFAULT 0,
    created_at BIGINT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_recovery_requests_claim ON recovery_requests(dead_lettered, available_at, lease_until);
CREATE INDEX IF NOT EXISTS idx_recovery_requests_created ON recovery_requests(created_at);

app_roles

One application role per account. Protected identity uses the unique nullable system_key; display names do not confer administrator access. See App authorization tables for column constraints.

CREATE TABLE IF NOT EXISTS app_roles (
    id UUID PRIMARY KEY,
    slug VARCHAR(80) NOT NULL UNIQUE,
    name VARCHAR(120) NOT NULL,
    description TEXT NOT NULL,
    is_enabled BOOLEAN NOT NULL DEFAULT true,
    system_key VARCHAR(40) UNIQUE,
    revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    CHECK (system_key IS NULL OR system_key IN ('platform_admin', 'user'))
);

app_permissions

Installed business and library definitions. The namespace ownership CHECK binds is_system to the exact lowercase goauth.app. prefix; library-owned definitions must remain enabled. Creating a definition grants nobody access.

CREATE TABLE IF NOT EXISTS app_permissions (
    id UUID PRIMARY KEY,
    permission_key VARCHAR(160) NOT NULL UNIQUE,
    name VARCHAR(120) NOT NULL,
    description TEXT NOT NULL,
    is_enabled BOOLEAN NOT NULL DEFAULT true,
    is_system BOOLEAN NOT NULL DEFAULT false,
    revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    CHECK (NOT is_system OR is_enabled),
    CONSTRAINT app_permissions_namespace_owner
        CHECK (is_system = (substr(permission_key, 1, 11) = 'goauth.app.'))
);

app_authorization_state

A single row (id=1) records initialization and the global authorization revision. Applying DDL creates no state row; trusted initialization does.

CREATE TABLE IF NOT EXISTS app_authorization_state (
    id SMALLINT PRIMARY KEY CHECK (id = 1),
    revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
    created_at TIMESTAMPTZ NOT NULL
);

users

Accounts. One row per person.

ColumnTypeNotes
idUUIDPrimary key, defaults to gen_random_uuid()
emailTEXTUnique, not null
password_hashTEXTNullable: OAuth-only accounts have no password
password_pepper_versionBIGINTNullable: NULL is unpeppered; a positive uint32 value selects one configured password-pepper key
nameTEXTDefaults to ''
app_role_idUUIDNullable role FK; deletion is restricted while assigned
app_role_assignment_revisionBIGINTNonnegative, defaults to 0
roleTEXTCHECK (role IN ('user', 'admin')), defaults to user
is_verified / verified_atBOOLEAN / TIMESTAMPTZEmail verification state
is_banned / banned_atBOOLEAN / TIMESTAMPTZBan state
two_factor_enabledBOOLEANDefaults to false
org_owner_countINTDenormalized counter, kept in sync by the org service
last_login_atTIMESTAMPTZ
created_at / updated_atTIMESTAMPTZ
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT,
    app_role_id UUID,
    app_role_assignment_revision BIGINT NOT NULL DEFAULT 0 CHECK (app_role_assignment_revision >= 0),
    password_pepper_version BIGINT CHECK (password_pepper_version > 0 AND password_pepper_version <= 4294967295),
    name TEXT NOT NULL DEFAULT '',
    role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
    is_verified BOOLEAN NOT NULL DEFAULT false,
    verified_at TIMESTAMPTZ,
    is_banned BOOLEAN NOT NULL DEFAULT false,
    banned_at TIMESTAMPTZ,
    two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    org_owner_count INT NOT NULL DEFAULT 0,
    last_login_at TIMESTAMPTZ,
    FOREIGN KEY (app_role_id) REFERENCES app_roles(id) ON DELETE RESTRICT
);

CREATE INDEX IF NOT EXISTS idx_users_password_pepper_version ON users(password_pepper_version);
CREATE INDEX IF NOT EXISTS idx_users_app_role ON users(app_role_id, id);
CREATE INDEX IF NOT EXISTS idx_users_role_created_at ON users (role, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_users_updated_at ON users (updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at);
CREATE INDEX IF NOT EXISTS idx_users_banned ON users (created_at DESC) WHERE is_banned;
CREATE INDEX IF NOT EXISTS idx_users_unverified ON users (created_at DESC) WHERE NOT is_verified;
CREATE INDEX IF NOT EXISTS idx_users_two_factor ON users (created_at DESC) WHERE two_factor_enabled;
CREATE INDEX IF NOT EXISTS idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_name_trgm ON users USING gin (name gin_trgm_ops);

app_role_permissions

Explicit role grants. The composite primary key prevents duplicate links. Role deletion cascades its links; permission deletion is restricted while links exist. Deleting the granting account clears granted_by without removing the grant.

CREATE TABLE IF NOT EXISTS app_role_permissions (
    role_id UUID NOT NULL,
    permission_id UUID NOT NULL,
    granted_by UUID,
    created_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (role_id, permission_id),
    FOREIGN KEY (role_id) REFERENCES app_roles(id) ON DELETE CASCADE,
    FOREIGN KEY (permission_id) REFERENCES app_permissions(id) ON DELETE RESTRICT,
    FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_app_role_permissions_permission ON app_role_permissions(permission_id, role_id);

organizations

Tenants.

ColumnTypeNotes
idUUIDPrimary key
nameVARCHAR(255)
slugVARCHAR(255)Unique
created_byUUID→ users(id) ON DELETE SET NULL: the org outlives its creator's account
owner_count / member_countINTDenormalized counters
metadataJSONBFree-form, defaults to {}
created_at / updated_atTIMESTAMPTZ
CREATE TABLE IF NOT EXISTS organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    created_by UUID REFERENCES users(id) ON DELETE SET NULL,
    owner_count INT NOT NULL DEFAULT 0,
    member_count INT NOT NULL DEFAULT 0,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

organization_members

The join table. Membership is the pair. There is no surrogate key.

ColumnTypeNotes
org_idUUID→ organizations(id) ON DELETE CASCADE
user_idUUID→ users(id) ON DELETE CASCADE
roleVARCHAR(50)CHECK on owner, admin, member
joined_atTIMESTAMPTZ
  • Composite primary key (org_id, user_id), so a user holds exactly one role per org.
  • role is enforced by CHECK on PostgreSQL only.
CREATE TABLE IF NOT EXISTS organization_members (
    org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role VARCHAR(50) NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
    joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (org_id, user_id)
);

CREATE INDEX IF NOT EXISTS idx_org_members_user ON organization_members(user_id);
CREATE INDEX IF NOT EXISTS idx_org_members_org_role ON organization_members(org_id, role);
CREATE INDEX IF NOT EXISTS idx_org_members_org_joined ON organization_members(org_id, joined_at);

organization_invites

Pending invitations to join an org.

ColumnTypeNotes
idUUIDPrimary key
org_idUUID→ organizations(id) ON DELETE CASCADE
emailTEXT
roleVARCHAR(50)The role they get. CHECK on owner, admin, member
code_hashTEXTUnique. The raw code goes out by email only
invited_byUUID→ users(id) ON DELETE CASCADE
expires_at / created_atTIMESTAMPTZ
CREATE TABLE IF NOT EXISTS organization_invites (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
    email TEXT NOT NULL,
    role VARCHAR(50) NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
    code_hash TEXT UNIQUE NOT NULL,
    invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_org_invites_org ON organization_invites(org_id);
CREATE INDEX IF NOT EXISTS idx_org_invites_email ON organization_invites(email);

sessions

One row per active login: both tokens, the device fingerprint, and the active-org pointer.

ColumnTypeNotes
idUUIDPrimary key
user_idUUID→ users(id) ON DELETE CASCADE
token_hashTEXTUnique. SHA-256 of the session token
refresh_token_hashTEXTSHA-256 of the current refresh token
prev_refresh_token_hashTEXTThe previous one, kept so the grace window can accept it briefly after rotation
refresh_rotated_atTIMESTAMPTZWhen the last rotation happened. The grace window is measured from here
ip_address / user_agentTEXTAs seen at creation
is_revoked / revoked_atBOOLEAN / TIMESTAMPTZ
created_atTIMESTAMPTZ
expires_at / refresh_expires_atTIMESTAMPTZAbsolute expiry for each token
last_active_atTIMESTAMPTZUpdated on authenticated requests, subject to TouchDebounce
active_org_idUUID→ organizations(id) ON DELETE SET NULL
active_org_roleVARCHAR(50)
two_factor_verified_atTIMESTAMPTZNullable; set only for a session created after successful 2FA. Preserved during refresh.
  • On PostgreSQL a table-level CHECK forces active_org_id and active_org_role to be both set or both null. A session cannot be scoped to an org without a role in it.
CREATE TABLE IF NOT EXISTS sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash TEXT UNIQUE NOT NULL,
    refresh_token_hash TEXT NOT NULL DEFAULT '',
    prev_refresh_token_hash TEXT NOT NULL DEFAULT '',
    ip_address TEXT NOT NULL DEFAULT '',
    user_agent TEXT NOT NULL DEFAULT '',
    is_revoked BOOLEAN NOT NULL DEFAULT false,
    expires_at TIMESTAMPTZ NOT NULL,
    refresh_expires_at TIMESTAMPTZ,
    refresh_rotated_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ,
    last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL,
    active_org_role VARCHAR(50),
    two_factor_verified_at TIMESTAMPTZ,
    CHECK ((active_org_id IS NULL AND active_org_role IS NULL) OR (active_org_id IS NOT NULL AND active_org_role IS NOT NULL))
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token_hash ON sessions(refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_prev_refresh_token_hash ON sessions(prev_refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user_active_org ON sessions(user_id, active_org_id);
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_last_active_at ON sessions (last_active_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_ip_address ON sessions (ip_address);

verification_tokens

One table behind nine short-lived token flows, discriminated by type.

ColumnTypeNotes
idUUIDPrimary key
user_idUUID→ users(id) ON DELETE CASCADE. Nullable: an invite-verify token can exist before the account does
emailTEXT
token_hashTEXTUnique
typeTEXTCHECK on verify_email, reset_password, set_password, invite_verify, oauth_state, delete_account, 2fa_login, 2fa_enable, 2fa_disable
expires_at / used_atTIMESTAMPTZused_at makes tokens single-use
code_verifierTEXTThe PKCE verifier, only populated for oauth_state rows
attempts / resend_countINTWrong-guess and resend counters for code challenges
created_atTIMESTAMPTZ
context_hashVARCHAR(64)Selected provider-name hash for new OAuth state; invitation code hash for pending invitation proof; first-factor method hash for login 2FA; initiating session hash for an optional 2FA setting confirmation. Empty on older tokens. SQLite uses TEXT.
encrypted_payloadTEXTEncrypted invitation code hash on invited OAuth state, or encrypted pending profile before invitation email confirmation; empty otherwise
  • The nine type values are enforced by CHECK on PostgreSQL only; on SQLite and MySQL the column is unconstrained.
CREATE TABLE IF NOT EXISTS verification_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    email TEXT NOT NULL,
    token_hash TEXT UNIQUE NOT NULL,
    type TEXT NOT NULL CHECK (type IN ('verify_email', 'reset_password', 'set_password', 'invite_verify', 'oauth_state', 'delete_account', '2fa_login', '2fa_enable', '2fa_disable')),
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ,
    code_verifier TEXT,
    attempts INT NOT NULL DEFAULT 0,
    resend_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    context_hash VARCHAR(64) NOT NULL DEFAULT '',
    encrypted_payload TEXT NOT NULL DEFAULT ''
);

CREATE INDEX IF NOT EXISTS idx_verification_tokens_token_hash ON verification_tokens(token_hash);

provider_accounts

Linked OAuth identities. A user may have several. Provider names and subject IDs match case-sensitively on all drivers. MySQL uses utf8mb4_0900_bin for both columns so trailing spaces and distinct Unicode sequences also remain distinct.

ColumnTypeNotes
idUUIDPrimary key
user_idUUID→ users(id) ON DELETE CASCADE
provider / provider_user_idTEXTUNIQUE(provider, provider_user_id): one identity links to one account
provider_email / provider_name / avatar_urlTEXTProfile snapshot from the provider
access_token / refresh_tokenTEXTEncrypted at rest; never returned by any endpoint
token_expires_atTIMESTAMPTZ
created_at / updated_atTIMESTAMPTZ
CREATE TABLE IF NOT EXISTS provider_accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    provider TEXT NOT NULL,
    provider_user_id TEXT NOT NULL,
    provider_email TEXT NOT NULL DEFAULT '',
    provider_name TEXT NOT NULL DEFAULT '',
    avatar_url TEXT NOT NULL DEFAULT '',
    access_token TEXT,
    refresh_token TEXT,
    token_expires_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(provider, provider_user_id)
);

CREATE INDEX IF NOT EXISTS idx_provider_accounts_user_id ON provider_accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_provider_accounts_provider ON provider_accounts(provider, provider_user_id);

invites

Signup invites, for invite-only registration.

ColumnTypeNotes
idUUIDPrimary key
emailTEXTWho it was issued to
codeTEXTUnique. Holds the SHA-256 hash despite the name. The raw code only goes out by email
created_byUUID→ users(id): the admin who issued it
statusTEXTCHECK on pending, accepted, revoked, expired
expires_at / accepted_at / created_atTIMESTAMPTZ
  • status is enforced by CHECK on PostgreSQL only.
  • Contrast organization_invites.code_hash, which names the same thing honestly.
CREATE TABLE IF NOT EXISTS invites (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT NOT NULL,
    code TEXT UNIQUE NOT NULL,
    created_by UUID NOT NULL REFERENCES users(id),
    status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
    expires_at TIMESTAMPTZ NOT NULL,
    accepted_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_invites_email ON invites(email);
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
CREATE INDEX IF NOT EXISTS idx_invites_status_created_at ON invites (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invites (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_invites_expires_at ON invites (expires_at);
CREATE INDEX IF NOT EXISTS idx_invites_pending_expires ON invites (expires_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_invites_email_trgm ON invites USING gin (email gin_trgm_ops);

audit_log

Written in the caller's transaction by audit.Service.Record when audit is enabled — a successfully written record commits or rolls back with the state change it describes. Default fail-open recording can allow a change to commit without a record when insertion fails. Rows are insert-only and never updated. The table name is singular audit_log. The admin route that reads it is /admin/audit-logs.

ColumnTypeNotes
idUUIDPrimary key
event_typeTEXTe.g. user.login, admin.ban
severityTEXTDefaults to info
successBOOLEANDefaults to true
actor_id / target_id / session_id / org_idUUIDAll nullable and deliberately not foreign keys. Audit rows must survive the deletion of what they describe
ip / user_agent / parsed_uaTEXT / JSONB
request_id / correlation_idTEXTFor stitching events to your own request tracing
metadataJSONBEvent-specific payload
created_atTIMESTAMPTZ
  • PostgreSQL additionally indexes metadata with a GIN index for containment queries.
  • Pre-resolution email events keep actor_id null and store the address in metadata.email; email text is never written to PostgreSQL's UUID column.
CREATE TABLE IF NOT EXISTS audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type TEXT NOT NULL,
    severity TEXT NOT NULL DEFAULT 'info',
    success BOOLEAN NOT NULL DEFAULT true,
    actor_id UUID,
    target_id UUID,
    session_id UUID,
    org_id UUID,
    ip TEXT,
    user_agent TEXT NOT NULL DEFAULT '',
    parsed_ua JSONB,
    request_id TEXT NOT NULL DEFAULT '',
    correlation_id TEXT NOT NULL DEFAULT '',
    metadata JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_target_id ON audit_log(target_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_session_id ON audit_log(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_org_id ON audit_log(org_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type_created_at ON audit_log(event_type, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_metadata ON audit_log USING GIN (metadata jsonb_path_ops);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_created_at ON audit_log (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_target_created_at ON audit_log (target_id, created_at DESC);

audit_outbox

The durable-delivery queue, created alongside audit_log when the embedded schema is applied. A row exists only while its event still must be delivered to an external sink: written in the same transaction as the record, deleted on successful delivery. Every retry UPDATE, claim, dead-letter, and eviction lands on this table — never on audit_log, which stays append-only and partition-friendly. audit_log_id deliberately has no foreign key (a check on the credential path would slow every login, and partition drops must not cascade); orphaned obligations are found by the janitor's periodic scan.

ColumnNotes
audit_log_idPK; the event's id, same dialect type as audit_log.id
org_idTenant attribution, for per-org delivery routing
priorityDispatch tier: 0 critical ships first, 1 best-effort (attacker-floodable failure events like login.failed)
attemptsDelivery attempts so far; at MaxAttempts the row is marked dead-lettered, not deleted
next_attempt_atExponential-backoff schedule (10s base, capped at 1h)
claimed_at / claim_ownerClaim lease for multi-instance dispatch; a stale claim is stealable (a benign duplicate under at-least-once delivery)
last_errorLast failure, for operators
dead_lettered_atTerminal-failure timestamp; retained for DeadLetterTTL as failure evidence
created_atTTL basis for the OutboxMaxAge sweep
CREATE TABLE IF NOT EXISTS audit_outbox (
    audit_log_id UUID PRIMARY KEY,
    org_id UUID,
    priority INT NOT NULL DEFAULT 0,
    attempts INT NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    claimed_at TIMESTAMPTZ,
    claim_owner TEXT NOT NULL DEFAULT '',
    last_error TEXT NOT NULL DEFAULT '',
    dead_lettered_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_audit_outbox_claim ON audit_outbox(priority, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_audit_outbox_created_at ON audit_outbox(created_at);

App authorization tables

DDL includes these tables on all three drivers, even when the runtime feature is disabled. Applying the schema creates no records. Initialization is explicit.

TableColumns and constraints
app_rolesid PK, unique immutable slug, name, description, is_enabled, nullable unique system_key, positive revision, created_at, updated_at. Protected identity is platform_admin or user.
app_permissionsid PK, unique immutable permission_key, name, description, is_enabled, is_system, positive revision, timestamps. app_permissions_namespace_owner requires is_system exactly for the lowercase goauth.app. prefix. Library definitions must remain enabled.
app_role_permissionsComposite PK (role_id,permission_id), role FK CASCADE, permission FK RESTRICT, nullable granted_by user FK SET NULL, created_at. Reverse index on (permission_id,role_id).
app_authorization_stateSingle row with PK id=1, positive revision, created_at. Serializes authorization writes after the account guard.
users additionsNullable app_role_id role FK RESTRICT, nonnegative app_role_assignment_revision default 0; index (app_role_id,id).

Initial admin/user roles start at revision 1. Assignment stores one role ID on the account, rather than a many-to-many user-role join. Grant replacement increments role revision; assignment increments account assignment revision. Definitions have their own metadata revision. The app revision marks management changes; it is not used as a session permission cache.

MySQL permission keys use binary collation. Role/permission names are at most 120 characters, slugs 80 and keys 160; services validate canonical identifiers before queries. IDs use UUID/TEXT/VARCHAR(36) according to the existing driver conventions. Authorization timestamps use TIMESTAMPTZ, DATETIME and DATETIME(6) on PostgreSQL, SQLite and MySQL respectively.

Driver differences

Three files living in internal/schema/ and embedded by //go:embed. The table and column names are identical across all three. Types, defaults, collations, constraints and indexes differ.

PostgreSQLSQLiteMySQL
IDsUUID, defaulted by gen_random_uuid()TEXT, generated by the applicationVARCHAR(36), generated by the application
TimestampsTIMESTAMPTZDATETIMEDATETIME, DATETIME(6) on the org tables
Recovery queue timesBIGINT UTC Unix secondsBIGINT UTC Unix secondsBIGINT UTC Unix seconds
BooleansBOOLEANINTEGER (0/1)BOOLEAN
JSON columnsJSONBTEXTJSON for metadata and parsed_ua
CHECK constraintsExisting domain checks plus app identity/revision/ownership checksPepper version plus app identity/revision/ownership checksApp identity/revision/ownership checks
App indexesUser-role and reverse grant indexesUser-role and reverse grant indexesUser-role and reverse grant indexes
Re-running goauth migrateCompleted IDs are skipped; schema is verifiedCompleted IDs are skipped; schema is verifiedCompleted IDs are skipped; incomplete DDL requires verified recovery
Foreign keysinline REFERENCESinline REFERENCESnamed CONSTRAINT ... FOREIGN KEY
Timestamp precisionTIMESTAMPTZDATETIMEDATETIME(6) (microseconds) on the org tables
Placeholders$1, $2, …: left as-is for PostgreSQL, rewritten to ? by sqlstore.DB.Rebind??

Queries are written once with $N placeholders and rewritten per driver at runtime, so none of this leaks into application code.

Constraints are not portable

Fixed account-role/status constraints vary by driver. Application authorization identity/revision/ownership checks exist in all three fresh-install schemas; MySQL requires a version that enforces CHECK constraints. Service validation remains authoritative on every driver. Database access can bypass service policies such as protected metadata and last-admin invariants.

Next

email_change_requests

Revision 4 adds an account slot keyed by user_id, with a cascading user FK. It stores request ID, initiating session/scope, old/new addresses, HMAC code hashes, an HMAC credential-state fingerprint, status and abuse counters. created_at, expires_at, budget_started_at, current_sent_at and new_sent_at are UTC Unix microseconds. There are no raw confirmation codes or password verifiers in this table. PostgreSQL uses a UUID user key; MySQL uses VARCHAR(36); SQLite uses TEXT.

Replacement uses a fresh request ID while preserving the hourly start budget. Used/cancelled slots clear their code hashes. Slots remain until replacement or account deletion; redemption enforces expiry. Revoking a session does not remove its account's budget. The additive migration preserves existing account data. Completion enqueues two email_changed items in recovery_requests, each with only its destination and fixed kind, and no raw credential.

Redis cache versions

sessions.cache_version and verification_tokens.cache_version are VARCHAR(36) NOT NULL DEFAULT 'legacy', placed last in each column list. Migration revision 5 preserves existing credential data and backfills the sentinel. Legacy rows bypass caching; library creation/mutation assigns fresh opaque UUID versions atomically. The metadata is internal and not exposed in public JSON.

On this page