---
title: "Custom Database"
url: "https://effect-auth.itsbroly.com/storage/custom-database/"
description: "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](/storage/) for ownership and migration guidance and the [Storage reference](/reference/storage/) 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 [#core-contracts]

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

| Contract            | Responsibility                                              | Important invariants                                                                                                        |
| ------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `UserStore`         | Users, email lookup, and email verification                 | Keep email lookup consistent with creation uniqueness and normalization. Preserve metadata, disabled state, and timestamps. |
| `CredentialStore`   | Password credentials                                        | Store hashes without transforming them. Enforce at most one active, non-revoked password credential per user.               |
| `SessionStore`      | Session lookup, lifecycle, claims, rotation, and revocation | Return expired or revoked rows from `findById`; policy evaluates them. Partial updates must preserve omitted fields.        |
| `VerificationStore` | One-time challenges and expiry cleanup                      | Consumption 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 [#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 [#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:

```sql
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 [#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.

```ts
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-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](/reference/storage/) when upgrading because newly enabled features or changed contracts may require schema, transaction, and test updates.

