Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/auth-sqlite v2.0.0 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/auth-sqlite ​

Durable, SQLite-backed implementations of every @basaltkit/auth store — users, sessions, refresh tokens, one-time tokens, API keys, MFA state, OAuth/OIDC account links and WebAuthn passkeys — built on Node's built-in node:sqlite. Zero external dependencies.

API keys may have an optional expiration date. Existing databases are migrated automatically when opened; expired keys are rejected and omitted from listings.

@basaltkit/auth ships in-memory stores that are perfect for dev and tests but lose everything on restart. Swap in these and your users stay logged in, your API keys keep working, and password-reset tokens survive a redeploy — no ORM, no migration tool, no service to run. It's the reference "real backend" for auth and the pattern other durable stores follow.

bash
pnpm add @basaltkit/auth-sqlite   # peer: @basaltkit/auth

> Requires Node 22.5+. On Node 24 node:sqlite is stable and needs no flag; > on 22.x run with --experimental-sqlite.

Use it ​

sqliteAuthStores() opens (or creates) the database, applies the schema, and returns every store named to drop straight into the auth plugins:

ts
import { authPlugin, apiKeysPlugin } from '@basaltkit/auth'
import { sqliteAuthStores } from '@basaltkit/auth-sqlite'

const s = sqliteAuthStores('./data/auth.db')   // ':memory:' by default

const app = await createApp({
  plugins: [
    authPlugin({
      secret: process.env.AUTH_SECRET!,
      users: s.users,
      sessions: s.sessions,
      refreshTokens: s.refreshTokens,
      tokens: s.tokens,   // email verification + password reset
      mfa: s.mfa,
      accountLinks: s.accountLinks, // OAuth/OIDC: provider subject → account
    }),
    apiKeysPlugin({ store: s.apiKeys, users: s.users }),
    webauthnPlugin({ config, verifier, credentials: s.passkeys }),
  ],
}).boot()

That's the whole change — the rest of your auth code is untouched, because these classes implement the exact same store contracts as the in-memory ones.

Bulk contact lookup (findByIds) ​

SqliteUserSource.findByIds(ids) resolves a set of accounts in one WHERE id IN (…) instead of one statement per id — the fast path behind @basaltkit/teams' roleRecipients ("email every admin of this tenant").

It selects only id, email and email_verified (the password hash is never read), keeps the order of ids, omits ids with no row, and chunks the list at 500 ids per statement so it stays inside SQLite's per-statement variable cap (SQLITE_MAX_VARIABLE_NUMBER — 999 on older builds). Tune it with new SqliteUserSource(db, { idChunkSize: 1000 }).

Emails are case-insensitive ​

findByEmail matches regardless of case, and create refuses an email that exists in any letter case with EmailTakenError (409) — checked inside the INSERT itself, so two concurrent sign-ups of Ana@x and ana@x cannot both land. A NOCASE unique index backs it on new databases; a legacy database that already holds case-variant duplicates cannot take that index, but create still refuses new variants, and a lookup of a duplicated email throws AccountEmailAmbiguousError (AUTH_EMAIL_AMBIGUOUS) instead of silently resolving to the oldest row.

normalizeAuthUserEmails(db, { dryRun? }) is the one-off cleanup: it lowercases every mixed-case email with no twin, returns { normalized, conflicts } (the twins, for you to merge by hand) and, once there are none, builds the index.

s.accountLinks (auth_account_links, primary key (provider, subject)) binds OAuth/OIDC logins to the provider's subject — see authPlugin({ accountLinks }). s.passkeys (auth_passkeys) is a durable PasskeyStore; its compareAndSetCounter is one conditional UPDATE … WHERE id = ? AND counter = ?, so two concurrent assertions of a cloned authenticator cannot both pass. Both tables are created by migrate() on existing databases too.

Pick individual stores ​

Every store is exported on its own and takes a DatabaseSync, so you can mix backends — e.g. keep sessions in Redis but users in SQLite:

ts
import { openAuthDatabase, SqliteUserSource, SqliteSessionStore } from '@basaltkit/auth-sqlite'

const db = openAuthDatabase('./data/auth.db')
const users = new SqliteUserSource(db)
const sessions = new SqliteSessionStore(db)
ExportContractTable
SqliteUserSourceUserSourceauth_users
SqliteSessionStoreSessionStoreauth_sessions
SqliteRefreshTokenStoreRefreshTokenStoreauth_refresh_tokens
SqliteAuthTokenStoreAuthTokenStoreauth_tokens
SqliteApiKeyStoreApiKeyStoreauth_api_keys
SqliteMfaStoreMfaStoreauth_mfa
SqliteTokenVersionStoreTokenVersionStoreauth_token_versions
SqliteAccountLinkStoreAccountLinkStoreauth_account_links
SqlitePasskeyStorePasskeyStoreauth_passkeys

Bring your own database handle ​

sqliteAuthStores() also accepts a DatabaseSync you already opened (it runs the idempotent migration on it), so auth can share one connection with the rest of your app:

ts
import { DatabaseSync } from 'node:sqlite'
import { sqliteAuthStores } from '@basaltkit/auth-sqlite'

const db = new DatabaseSync('./data/app.db')
const s = sqliteAuthStores(db)   // creates the auth_* tables if missing

openAuthDatabase(location) and migrate(db) are exported if you'd rather wire things up yourself.

Notes ​

  • Schema is created with CREATE TABLE IF NOT EXISTS, so migrate() is safe to run on every boot. WAL journaling is enabled for concurrent reads.
  • Secrets are never stored in the clear — the same as the in-memory stores. API keys persist only their SHA-256 hash and a display prefix; MFA recovery codes are stored hashed by @basaltkit/auth before they reach the store.
  • Expired sessions are evicted lazily on lookup, matching the in-memory store's behavior.
  • node:sqlite is synchronous; the methods stay async to honor the contracts, so there's no behavioral difference for callers.

When to reach for something else ​

SQLite is an excellent default for single-node deployments and is genuinely production-grade. If you run multiple instances that must share auth state, point sessions/refresh tokens at Redis (@basaltkit/auth's Redis stores) and keep users in your primary database. These SQLite stores are the reference implementation of the durable-store pattern — copy them for Postgres/MySQL when you need it.

License ​

MIT

Released under the MIT License.