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 ships one canonical schema per database driver, embedded into the library at build time. Nine tables and around twenty indexes, no ORM, no runtime CREATE TABLE — you apply the schema once, before your app starts.

The column tables below describe the PostgreSQL schema. Table and column names are identical on SQLite and MySQL; types and constraints differ — see Driver differences.

There is no migration system yet

go-auth ships a single canonical schema per driver, not versioned migrations. There is no schema_migrations table, no up/down steps, and no diffing against what you already have. Every CREATE TABLE is IF NOT EXISTS, which means applying the schema over an existing database will never alter a table that already exists in an older shape — it succeeds, changes nothing, and leaves you on the old columns.

Until a migration system lands, treat schema changes between releases as breaking: regenerate the schema file, diff it against the one you applied last, and write the ALTER TABLE statements yourself.

Applying the schema

Three routes to the same SQL. Pick whichever fits how you deploy.

Connects and applies the schema directly:

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

The @latest is not optional: the CLI is its own module, so without a version suffix go run looks for the package in your module's dependency graph and fails with no required module provides package.

Both flags are required. It opens the connection, pings it, then executes each statement in order, printing OK: <statement> for each one.

Installing the CLI properly, rather than go run:

go install github.com/nazimdjebloun/go-auth/cmd/goauth@latest

It's a separate module with its own go.mod, and it blank-imports all three database drivers, so it works against any of them without you wiring anything up.

Driver names accept aliases in all three routes: postgres or pg, sqlite or sqlite3, and mysql.

PostgreSQL: the first statement is CREATE EXTENSION

The PostgreSQL schema opens with CREATE EXTENSION IF NOT EXISTS "pgcrypto", which is what supplies the gen_random_uuid() default on every primary key. It needs privileges some managed providers don't hand out, so this is the statement most likely to fail first on a locked-down instance — and because migrate exits on the first failure, nothing after it runs.

If your provider blocks it, have a superuser create the extension once beforehand; the IF NOT EXISTS then makes the library's copy a no-op. On PostgreSQL 13 and later gen_random_uuid() is built in and the extension isn't strictly needed, but the statement is still issued.

migrate is not transactional, and is only re-runnable on PostgreSQL and SQLite

Statements execute one at a time and the command exits on the first failure, so a partial failure leaves the schema partly applied — whatever was created before the failing statement stays.

On PostgreSQL and SQLite every statement is IF NOT EXISTS, so re-running after you fix the cause picks up where it stopped. MySQL is different: CREATE INDEX has no IF NOT EXISTS form there, so all 20 index statements are unguarded and a second run fails with Duplicate key name on the first index that already exists. The tables survive — the command just can't be used as a no-op re-check on MySQL. Don't point it at a production database without a backup.

The tables

Nine tables. Every foreign key to users is ON DELETE CASCADE, so deleting a user takes their sessions, tokens, linked OAuth accounts, and org memberships with it. The one exception is sessions.active_org_id, which is ON DELETE SET NULL — deleting an org clears it from any session pointing at it rather than destroying the session.

No table stores a raw credential. Passwords are bcrypt hashes; session tokens, refresh tokens, verification tokens, and both kinds of invite code are stored as SHA-256 hashes, and the raw value exists only in the response to the client.

Each table gives a column-by-column summary — the types shown are the PostgreSQL ones — followed by the exact DDL the library applies on each driver, indexes included.

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
nameTEXTDefaults to ''
roleTEXTCHECK (role IN ('user', 'admin')), defaults to user
is_verified / verified_atBOOLEAN / TIMESTAMPTZEmail verification state
is_banned / banned_atBOOLEAN / TIMESTAMPTZBan state
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,
    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,
    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
);

sessions

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

ColumnTypeNotes
idUUIDPrimary key
user_idUUIDusers(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
parsed_uaJSONBBrowser/OS/device parsed from the user agent
is_revoked / revoked_atBOOLEAN / TIMESTAMPTZ
expires_at / refresh_expires_atTIMESTAMPTZAbsolute expiry for each token
last_active_atTIMESTAMPTZUpdated on authenticated requests, subject to TouchDebounce
active_org_idUUIDorganizations(id) ON DELETE SET NULL
active_org_roleVARCHAR(50)
  • 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 '',
    parsed_ua JSONB,
    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),
    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);

verification_tokens

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

ColumnTypeNotes
idUUIDPrimary key
user_idUUIDusers(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
expires_at / used_atTIMESTAMPTZused_at makes tokens single-use
code_verifierTEXTThe PKCE verifier, only populated for oauth_state rows
  • The six 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')),
    expires_at TIMESTAMPTZ NOT NULL,
    used_at TIMESTAMPTZ,
    code_verifier TEXT
);

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.

ColumnTypeNotes
idUUIDPrimary key
user_idUUIDusers(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_byUUIDusers(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);

organizations

Tenants.

ColumnTypeNotes
idUUIDPrimary key
nameVARCHAR(255)
slugVARCHAR(255)Unique
created_byUUIDusers(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_idUUIDorganizations(id) ON DELETE CASCADE
user_idUUIDusers(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);

organization_invites

Pending invitations to join an org.

ColumnTypeNotes
idUUIDPrimary key
org_idUUIDorganizations(id) ON DELETE CASCADE
emailTEXT
roleVARCHAR(50)The role they'll get — CHECK on owner, admin, member
code_hashTEXTUnique. The raw code goes out by email only
invited_byUUIDusers(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);

audit_log

Written asynchronously by the audit pipeline when it is enabled. 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
  • PostgreSQL additionally indexes metadata with a GIN index for containment queries.
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_metadata ON audit_log USING GIN (metadata jsonb_path_ops);

Driver differences

Three files, roughly 150 lines each, living in internal/schema/ and embedded by //go:embed. The table and column names are identical across all three — only types and defaults differ.

PostgreSQLSQLiteMySQL
IDsUUID, defaulted by gen_random_uuid()TEXT, generated by the applicationVARCHAR(36), generated by the application
TimestampsTIMESTAMPTZDATETIMEDATETIME
BooleansBOOLEANINTEGER (0/1)BOOLEAN
JSON columnsJSONBTEXTmixed — JSON for metadata, TEXT for parsed_ua
CHECK constraints6 — role, status, token type, org role, and the session active-org pairnonenone
Indexes212020
Re-runnableyes — every statement is IF NOT EXISTSyesnoCREATE INDEX is unguarded
Foreign keysinline REFERENCESinline REFERENCESnamed CONSTRAINT ... FOREIGN KEY
Timestamp precisionTIMESTAMPTZDATETIMEDATETIME(6) — microseconds
Placeholders$1, $2, … — rewritten by sqlstore.DB.Rebind??

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

Constraints are not portable

The CHECK constraints listed against each table above exist only on PostgreSQL. On SQLite and MySQL the same columns are plain TEXT/VARCHAR with no database-level restriction, so an invalid role or status written by something other than this library will be accepted by the database. The service layer validates these values regardless of driver — the constraints are a second line of defence that only PostgreSQL gives you.

Next

On this page