effect-auth

Custom Database

Implement effect-auth contracts for an unsupported database or existing schema.

Use a custom database adapter when effect-auth does not support your database, or when authentication must use an existing schema. You do not need to copy the maintained SQLite schema. Instead, map your tables, documents, or keys to the public Effect services and preserve their behavior.

Read the Storage Overview for ownership and migration guidance and the Storage reference for complete row, input, and method definitions. This guide concentrates on the decisions that make a custom implementation correct.

There is no generic Storage service or makeStorage factory. Implement each service required by the features in your application, construct it with its make helper, and provide its tag in a Layer.

Core contracts

The base services are exported by @effect-auth/core/Storage:

ContractResponsibilityImportant invariants
UserStoreUsers, email lookup, and email verificationKeep email lookup consistent with creation uniqueness and normalization. Preserve metadata, disabled state, and timestamps.
CredentialStorePassword credentialsStore hashes without transforming them. Enforce at most one active, non-revoked password credential per user.
SessionStoreSession lookup, lifecycle, claims, rotation, and revocationReturn expired or revoked rows from findById; policy evaluates them. Partial updates must preserve omitted fields.
VerificationStoreOne-time challenges and expiry cleanupConsumption must admit one winner and reject missing, consumed, expired, or type-mismatched rows.

Use the exact exported *StoreService, row, and input types. Branded identifiers are runtime strings, while UnixMillis is a finite number of milliseconds. Adapt those values at the database boundary. Preserve optional fields and structured values such as metadata, claims, and authentication methods without silently dropping data. Use caller-supplied mutation timestamps rather than replacing them with database time.

An existing schema may use different names, split records across tables, or store JSON as text. That is an adapter concern. The returned object must still match the public row type, and one contract operation must retain its documented transactional behavior. Add indexes and constraints where needed rather than relying only on TypeScript checks; email uniqueness and active-credential uniqueness are common examples.

Feature-owned contracts

Core storage is only the baseline. Features define their own services in their public modules, for example PasskeyCredentialStore in @effect-auth/core/Passkey, RefreshTokenStore in @effect-auth/core/RefreshToken, and stores in Totp, RecoveryCode, and OAuth. API keys, trusted devices, domain verification, audit data, security timelines, and webhooks likewise own focused contracts.

Implement only the stores required by the program you compose. The Effect environment type will expose missing services. Do not provide placeholders that always return Option.none(); that hides a configuration error and can weaken feature behavior.

Feature contracts may be stronger than similarly named core operations. Examples include one-time recovery-code use, passkey counter compare-and-set, webhook claims, idempotent replay handling, and refresh-token rotation that retires one token while creating another. Consult the owning module's contract rather than inferring semantics from SessionStore or from your schema.

Atomicity and results

Authentication mutations must remain correct under concurrency. Prefer one conditional statement with a returned row, such as UPDATE ... WHERE ... RETURNING. Otherwise use a transaction whose locking and isolation guarantee the same outcome.

VerificationStore.consume, for example, is conceptually:

UPDATE auth_verification
SET consumed_at = :consumedAt
WHERE id = :id
  AND consumed_at IS NULL
  AND expires_at > :consumedAt
  AND (:typeWasOmitted OR type = :type)
RETURNING ...;

Exactly one concurrent caller may receive Option.some. Notice the exact expiry boundary: expiresAt <= consumedAt is expired. A select followed by an unconditional update is not sufficient.

Apply the same discipline to consume, claim, lease, rotation, and compare-and-set (CAS) contracts. Include expected state, version, hash, or terminal-state guards in the mutation predicate. Keep multi-row transitions in one transaction. Core SessionStore.rotate is an atomic row update but is not CAS because its input has no expected old hash or version; replay-safe refresh-token rotation belongs to RefreshTokenStore.

Methods returning Option distinguish a successful query or conditional mutation that matched no eligible row from a backend failure:

  • Return Option.none() for missing or ineligible data where the contract requests it.
  • Fail with StorageError for driver errors, timeouts, constraint failures, decode failures, and unsupported persisted values.

Map failures at the backend boundary with StorageError.fromUnknown(entity, operation, cause). Do not include secrets, hashes, or complete authentication rows in error messages. Void delete or revoke operations may define a missing row as a successful no-op; follow the reference rather than inventing an error.

Implementation skeleton

Keep database-specific queries and codecs behind a narrow interface. This example shows one implementation and its layer; implement the remaining required services in the same shape.

import { Effect, Layer, Option } from "effect";
import {
  StorageError,
  UserStore,
  type UserRow,
} from "@effect-auth/core/Storage";

interface Database {
  readonly findUserById: (id: string) => Promise<unknown | undefined>;
}

declare const decodeUser: (
  value: unknown
) => Effect.Effect<UserRow, StorageError>;

const makeUserStore = (db: Database) =>
  UserStore.of({
    findById: (id) =>
      Effect.tryPromise({
        try: () => db.findUserById(String(id)),
        catch: (cause) => StorageError.fromUnknown("user", "find", cause),
      }).pipe(
        Effect.flatMap((value) =>
          value === undefined
            ? Effect.succeed(Option.none<UserRow>())
            : decodeUser(value).pipe(Effect.map(Option.some))
        )
      ),
    insert: (row) => insertUser(db, row),
  });

export const UserStoreLive = (db: Database) =>
  Layer.succeed(UserStore)(makeUserStore(db));

UserStore.make checks and constructs the service value; it does not create a schema, connection, transaction manager, or layer. Merge the individual service layers required by your application with Layer.mergeAll.

An authentication adapter must also implement IdentityStore. Password registration additionally requires RegistrationStore, whose registerWithPassword operation reserves the normalized identity and inserts the user and credential in one database transaction. Do not emulate that capability with three independent service calls.

Test checklist

Test against the real database engine and its production transaction mode:

  1. Round-trip every required field, optional value, boolean, nested JSON value, branded ID, and millisecond timestamp.
  2. Verify missing-row Option behavior separately from driver, constraint, and decode failures mapped to StorageError.
  3. Cover uniqueness, active/revoked filtering, exact expiry boundaries, limits, ordering, and preservation of fields omitted from partial updates.
  4. Run concurrent consume, claim, and CAS attempts; assert exactly the permitted callers win and losing attempts leave state unchanged.
  5. Exercise rollback for multi-row operations and concurrent session or claims updates to detect lost writes.
  6. Test enabled feature stores, not only the four core services, including replay and terminal-state cases.

The effect-auth repository uses shared contract runners in packages/core/test/StorageContract.ts, but they are internal and are not exported by @effect-auth/core. Do not import test or dist subpaths. Use those tests as an executable specification and adapt the relevant cases into your adapter's own suite.

Type conformance catches shape mismatches; integration and concurrency tests establish storage correctness. Revisit the Storage reference when upgrading because newly enabled features or changed contracts may require schema, transaction, and test updates.

On this page