Data Model
Design principles
Auth tables are separate from product tables. Auth never writes to your billing, orders, or account tables. The only bridge is the
host_user_id/host_account_idinauth_identities, populated by yourresolveOrCreateViewerhook.The
auth_identitiestable exists from day one. Even when magic-link is the only provider, identities are stored in a dedicated table. This makes future provider migration a data operation, not a schema migration.Tokens are hashed at rest. Raw magic-link tokens are never stored. SHA-256 hash of the token is stored; the raw token exists only in the email URL.
Sessions are database-backed, not KV. D1 (or MySQL) provides transactional guarantees for token consumption. KV's eventual consistency creates a window where a token could be consumed twice.
Tables (D1)
auth_identities
Maps auth provider identity to host app's user/account.
CREATE TABLE auth_identities (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
provider TEXT NOT NULL, -- 'magic-link', 'firebase', 'better-auth'
provider_user_id TEXT NOT NULL, -- provider-specific user ID
email TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
host_user_id TEXT, -- populated by resolveOrCreateViewer
host_account_id TEXT, -- populated by resolveOrCreateViewer
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(provider, provider_user_id),
UNIQUE(provider, email)
);
CREATE INDEX idx_auth_identities_email ON auth_identities(email);
CREATE INDEX idx_auth_identities_host_user ON auth_identities(host_user_id);
auth_sessions
Active sessions.
CREATE TABLE auth_sessions (
id TEXT PRIMARY KEY, -- cryptographically random session ID
identity_id TEXT NOT NULL REFERENCES auth_identities(id),
provider TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_auth_sessions_identity ON auth_sessions(identity_id);
CREATE INDEX idx_auth_sessions_expires ON auth_sessions(expires_at);
auth_magic_tokens
Pending magic-link tokens (consumed and deleted on use).
CREATE TABLE auth_magic_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the raw token
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_auth_magic_tokens_hash ON auth_magic_tokens(token_hash);
CREATE INDEX idx_auth_magic_tokens_expires ON auth_magic_tokens(expires_at);
auth_rate_limits
Rate limiting counters.
CREATE TABLE auth_rate_limits (
key TEXT PRIMARY KEY, -- e.g., 'request:email:user@example.com' or 'verify:ip:1.2.3.4'
count INTEGER NOT NULL DEFAULT 1,
window_start TEXT NOT NULL DEFAULT (datetime('now')),
window_seconds INTEGER NOT NULL
);
Tables (MySQL — for WordPress)
Same schema adapted for MySQL syntax:
TEXTprimary keys becomeVARCHAR(64)INTEGERbooleans becomeTINYINT(1)datetime('now')becomesNOW()randomblobbecomesUUID()orHEX(RANDOM_BYTES(16))- Indexes are the same
Each storage adapter package (auth-storage-d1, auth-storage-mysql) owns its own migration files.
Token lifecycle
Generate raw token (32 bytes, hex-encoded)
→ Hash with SHA-256
→ Store hash in auth_magic_tokens with TTL
→ Send raw token in email URL
→ User clicks link
→ Hash the incoming token
→ Look up hash in auth_magic_tokens
→ If found and not expired: delete row (atomic), create session
→ If not found or expired: reject
The raw token never touches storage. The hash is a one-way function — a database leak doesn't expose usable tokens.
Session lifecycle
completeLogin produces AuthIdentity
→ Upsert auth_identities row
→ Generate session ID (32 bytes, hex-encoded)
→ Insert auth_sessions row with TTL
→ Set session cookie (HttpOnly, Secure, SameSite=Lax)
→ On each request: read cookie → load session → check expiry → return identity
→ On logout: delete session row, clear cookie
Identity mapping during provider migration
When a project migrates from magic-link to Firebase:
- User's existing identity:
auth_identities(provider='magic-link', email='user@example.com', host_user_id='usr_abc') - User logs in via Firebase: new identity row
auth_identities(provider='firebase', provider_user_id='firebase-uid-123', email='user@example.com', host_user_id='usr_abc') - Both rows map to the same
host_user_id resolveOrCreateViewerreceives either identity and returns the same Viewer- During dual-session window, old magic-link sessions still resolve via the old identity row
- After migration window, magic-link identity rows can be archived or retained as history
Subscriber auth data
For the subscribe widget, unknown emails go to the host app's mailing list, not to auth tables. The onNewSubscriber hook is responsible for storage — the SDK doesn't prescribe where subscriber data lives.
For the token minting path (known users), the flow uses the same auth_magic_tokens table as regular magic-link auth. The only difference is the email template and the post-confirmation redirect.