For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/auth/data-provider.md.

Data provider

@dotpress/auth stores nothing itself. A data provider, an object of a few functions, reads your users and stores the refresh tokens in your database, with the library of your choice.

The methods

MethodRequiredCalled
findUserIdentifiers(username)✓At login: { userId, passwordHash, role? }, or undefined for an unknown username. passwordHash is null for an account without password (SSO).
findUserById(userId)✓On every authenticated request and every refresh: the user, which becomes ctx.user
storeRefreshToken(payload)✓At login and at each refresh: { token, tokenHash, userId, jwtId, familyId, expiresAt }. Store the hash, never the token.
findRefreshToken(tokenHash)✓At each refresh and logout: { userId, jwtId, familyId, expiresAt, isUsed, usedAt, revokedAt }, or undefined. isUsed can be derived from usedAt.
markRefreshTokenAsUsed(tokenHash, usedAt)✓At each refresh
revokeTokenFamily(familyId)✓At logout, and when a stolen refresh token is detected: revokes one session
revokeTokens(userId)✓At logout of every device: revokes every session of the user
isSessionRevoked(sessionId)On every authenticated request: rejects the access tokens of a revoked session at once, see Revocation
updatePasswordHash(userId, passwordHash)After a successful login, when the cost of the hash changed, see Passwords
findUserById decides who is logged in

findUserById() must return undefined for a deleted, disabled or banned account. It is called on every request: the user is logged out at once, and their sessions are revoked at the next refresh.

Return only what the handlers need from findUserById(): it becomes ctx.user, and is returned by GET /auth/me. Never the password hash.

The refresh tokens table

A refresh token is stored by its SHA-256 hash, never in clear. The tokens of a session share a familyId, the session ID:

CREATE TABLE refresh_tokens (
  token_hash TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  jwt_id TEXT NOT NULL,
  family_id TEXT NOT NULL, -- the session
  expires_at TIMESTAMP NOT NULL,
  used_at TIMESTAMP,
  revoked_at TIMESTAMP
);
CREATE INDEX refresh_tokens_family_id ON refresh_tokens (family_id);
CREATE INDEX refresh_tokens_user_id ON refresh_tokens (user_id);

Expired tokens can be deleted by a scheduled job: DELETE FROM refresh_tokens WHERE expires_at < now().

An SQL data provider

The data provider of the Tasks API example, on SQLite. The queries are the same on PostgreSQL or MySQL, with their placeholders:

import type { DataProvider, RefreshTokenData } from '@dotpress/auth'
import { get, run, type Database } from '../../db/database.ts'
import type { UserRepository } from '../users/repository.ts'

interface RefreshTokenRow {
  user_id: string
  jwt_id: string
  family_id: string
  expires_at: string
  used_at: string | null
  revoked_at: string | null
}

/** What @dotpress/auth reads and writes: users and refresh tokens. */
export const createDataProvider = (
  db: Database,
  users: UserRepository
): DataProvider => ({
  storeRefreshToken: async ({
    tokenHash,
    userId,
    jwtId,
    familyId,
    expiresAt,
  }) => {
    run(
      db,
      'INSERT INTO refresh_tokens (token_hash, user_id, jwt_id, family_id, expires_at) VALUES (?, ?, ?, ?, ?)',
      tokenHash,
      userId,
      jwtId,
      familyId,
      expiresAt.toISOString()
    )
  },

  findRefreshToken: async (
    tokenHash
  ): Promise<RefreshTokenData | undefined> => {
    const row = get<RefreshTokenRow>(
      db,
      'SELECT * FROM refresh_tokens WHERE token_hash = ?',
      tokenHash
    )
    return (
      row && {
        userId: row.user_id,
        jwtId: row.jwt_id,
        familyId: row.family_id,
        expiresAt: new Date(row.expires_at),
        isUsed: row.used_at !== null,
        usedAt: row.used_at === null ? null : new Date(row.used_at),
        revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at),
      }
    )
  },

  markRefreshTokenAsUsed: async (tokenHash, usedAt) => {
    run(
      db,
      'UPDATE refresh_tokens SET used_at = ? WHERE token_hash = ?',
      usedAt.toISOString(),
      tokenHash
    )
  },

  revokeTokens: async (userId) => {
    run(
      db,
      'UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL',
      new Date().toISOString(),
      userId
    )
  },

  revokeTokenFamily: async (familyId) => {
    run(
      db,
      'UPDATE refresh_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL',
      new Date().toISOString(),
      familyId
    )
  },

  // A logout stops the access tokens of the session at once.
  isSessionRevoked: async (sessionId) =>
    get(
      db,
      'SELECT 1 FROM refresh_tokens WHERE family_id = ? AND revoked_at IS NOT NULL LIMIT 1',
      sessionId
    ) !== undefined,

  // On every authenticated request: becomes ctx.user.
  findUserById: (userId) => users.findById(userId),

  // At login: the username is the email address.
  findUserIdentifiers: (username) => users.findCredentials(username),

  updatePasswordHash: (userId, passwordHash) =>
    users.setPasswordHash(userId, passwordHash),
})

findUserById() and findUserIdentifiers() come from the users repository of the app: the user table stays yours, with your columns.

The type of the user

The type of ctx.user is the AuthUser interface, which you complete once, with module augmentation:

declare module '@dotpress/auth' {
  interface AuthUser {
    id: string
    email: string
    roles: string[]
  }
}

findUserById() must return this type. See Protecting routes.

In tests

A data provider over a Map is enough for the tests. The Auth example has one, in src/store.ts, used both by the app in development and by the tests.