npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@datar-platform/better-auth-dynamodb

v0.2.2

Published

A generic DynamoDB adapter for Better Auth. Bring your own store or use the built-in single-table store.

Readme

@datar-platform/better-auth-dynamodb

A generic DynamoDB adapter for Better Auth.

  • Works out of the box with any Better Auth model or plugin — the built-in single-table store derives its indexes from your schema (unique fields, foreign keys, and anything marked index: true), so two-factor, passkey, API-key, organization, etc. work with no per-model configuration. Queries no index can serve are refused rather than silently scanned, so an access pattern nobody designed for shows up in development (see unsafeAllowScan).
  • Bring your own store. The adapter talks to a small DynamoStore seam, so you can back it with an existing single-table design (ElectroDB, custom key encoding, a shared table) without changing the adapter.
  • Atomic uniqueness. unique fields are enforced by DynamoDB itself, in the same transaction as the row — not by a check-then-insert two concurrent sign-ups can both pass.
  • Safe under concurrency. Every row carries a revision that guards updates, deletes, and single-use consumes against lost writes.
  • No hidden scans, no silent truncation. A query no index can serve fails loudly instead of quietly reading the whole model, and pagination that hits its cap throws rather than returning part of an answer.
  • Passes Better Auth's official adapter conformance suites against real DynamoDB.
  • Zero dependencies beyond the AWS SDK.

Install

npm install @datar-platform/better-auth-dynamodb @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

Requires better-auth 1.7 or newer, and Node 22 or newer. On better-auth 1.5/1.6, stay on 0.1.x.

Quick start (built-in single-table store)

import { dynamoAdapter } from "@datar-platform/better-auth-dynamodb";
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: dynamoAdapter({
    tableName: "better-auth", // or set DYNAMODB_TABLE_NAME
    region: "us-east-1",
  }),
  emailAndPassword: { enabled: true },
});

Provision the table

The store uses one table with a byType GSI (to list a model) plus one generic lookup GSI per index a model needs. Create it once at startup or in setup:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  assignSlots,
  deriveIndexMap,
  ensureSchema,
} from "@datar-platform/better-auth-dynamodb";

// `schema` is your Better Auth instance's resolved DB schema.
const indexMap = deriveIndexMap(schema);
await ensureSchema({
  client: new DynamoDBClient({ region: "us-east-1" }),
  tableName: "better-auth",
  lookupSlots: assignSlots(indexMap).maxSlots,
});

You can also generate a CloudFormation template via the Better Auth CLI (npx @better-auth/cli generate) — the adapter's createSchema emits one sized to your access patterns.

Expiring sessions and verifications

Nothing expires on its own. Point the adapter at your expiry field and DynamoDB reaps expired rows for free:

dynamoAdapter({
  tableName: "better-auth",
  ttl: { defaultField: "expiresAt" },
});

Pass the same attribute to ensureSchema({ ..., ttlAttribute: "__ba_ttl" }) — the adapter writes the attribute, but only the table setting makes AWS act on it. Reads treat it as logical expiry too: DynamoDB reaps lazily, often a day or two late, so without that an expired session would keep working until AWS got round to deleting it.

Upgrading from 0.1.x

0.2.0 changed the physical key format, so rows written by 0.1.x survive but are no longer found by any lookup. Recreating the table is fine in development; if it holds real users, migrate it instead:

import {
  deriveIndexMap,
  migrateKeys,
} from "@datar-platform/better-auth-dynamodb";
import { getAuthTables } from "better-auth/db";

const indexMap = deriveIndexMap(getAuthTables(betterAuthOptions));

// Look before you leap: a dry run writes nothing, and names any duplicate that
// would block the migration.
console.log(await migrateKeys({ tableName, indexMap, client, dryRun: true }));

await migrateKeys({ tableName, indexMap, client });

It rewrites each row into the current format and backfills the uniqueness markers 0.1.x never wrote, so existing rows end up protected rather than merely readable. Nothing happens automatically on upgrade — rewriting an auth table on first boot is not a decision this package makes for you — and running it on a table with nothing to migrate is a no-op, so it is safe in a deploy step and safe to run twice.

If two old rows claim the same unique value, it stops before writing anything and reports their ids. 0.1.x enforced uniqueness in Better Auth's application layer, which two concurrent sign-ups could both pass; deciding which row keeps the email is yours to make, not the migration's.

Uniqueness

Fields the Better Auth schema marks unique — user.email, session.token, organization.slug — get a marker row written in the same TransactWriteItems as the row itself, conditional on the marker not already existing. That makes uniqueness a property of the database: of two concurrent sign-ups for the same email, exactly one commits.

Better Auth's own enforcement is a check-then-insert, which both racers can pass. Set atomicUniqueness: false to fall back to it and halve the write cost of creates.

Two things worth knowing:

  • On a table upgraded from 0.1.x, markers only cover rows written since the upgrade until you run migrateKeys(), which backfills them for the rows already there.
  • Do not write Better Auth rows into the table with a raw PutItem. Entity rows, index keys, markers, and TTL attributes have to move together.

A write that loses a uniqueness race throws UniqueConstraintError, so you can tell "this email is taken" apart from a failed write:

import { UniqueConstraintError } from "@datar-platform/better-auth-dynamodb";

DynamoDBAdapterError is the base of everything this package throws; UnsupportedQueryError and OptimisticLockError are the other two you might catch by name.

Bring your own store

Implement DynamoStore to run the same adapter against your own table layout. The adapter drives it purely through logical index names + field values — it never touches physical PK/SK strings, so your store owns all key encoding.

import { dynamoAdapter } from "@datar-platform/better-auth-dynamodb";

import type {
  DynamoStore,
  IndexMap,
} from "@datar-platform/better-auth-dynamodb";

const store: DynamoStore = {
  put(model, item) {
    /* ... */
  },
  getById(model, id) {
    /* ... */
  },
  update(model, id, patch) {
    /* ... */
  },
  deleteById(model, id) {
    /* ... */
  },
  queryIndex({ model, index, key }) {
    /* map (model, index, key) -> a GSI query */
  },
  listByType({ model }) {
    /* list all rows of a model */
  },
  // Optional, and worth implementing: `consumeOne` (atomic delete-and-return,
  // which is what stops a one-time code being used twice) and `incrementOne`
  // (atomic counter). Without them the adapter falls back to a non-atomic
  // get-then-write. Also optional: `count()`, `createSchema()`.
};

// Describe which fields each model is looked up by:
const indexMap: IndexMap = {
  user: [{ index: "byEmail", pk: ["email"] }],
  account: [
    { index: "byUser", pk: ["userId"], sk: ["providerId"] },
    { index: "byAccountId", pk: ["accountId"], sk: ["providerId"] },
  ],
  // ...
};

betterAuth({ database: dynamoAdapter({ store, indexMap }) });

Given a where, the adapter takes the cheapest path it can prove: an id equality becomes a direct get, an id in [...] becomes a bounded set of gets, and otherwise it picks the first access pattern whose partition-key fields are all present (attaching any leading sort-key fields). Whatever the chosen path does not cover is filtered in memory. Provide an indexMap to match your table, or omit it to auto-derive from the schema.

Configuration

| Option | Default | Description | | ------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | store | built-in single-table store | Storage backend (DynamoStore). | | indexMap | derived from schema | Access-pattern map. | | tableName | DYNAMODB_TABLE_NAME → better-auth | Table name (built-in store). | | region | SDK default | AWS region (built-in store). | | endpoint | SDK default | Endpoint override for DynamoDB Local / LocalStack, e.g. http://localhost:8000 (built-in store). | | documentClient | created internally | Pre-built DynamoDBDocumentClient. Preferred in production, so your app owns credentials, region, middleware, and tracing. | | atomicUniqueness | true | Enforce unique fields with transactional marker rows. | | ttl | disabled | Adapter-managed DynamoDB TTL, e.g. { defaultField: "expiresAt" }. | | maxPages | 25 | Cap on pages drained per query. Throws at the cap rather than returning a partial result. | | pageSize | SDK default | Per-request DynamoDB Limit. Changes request sizing only, not logical results. | | unsafeAllowScan | false | Allow queries no index can serve, which read every row of the model and filter in memory. | | debugLogs | false | Better Auth debug logging. |

Notes & limitations

  • IDs are strings (Better Auth generates them); supportsNumericIds is false.
  • Dates are stored as ISO strings and re-hydrated on read (supportsDates: false).
  • No interactive transactions — DynamoDB has no such API, so the adapter reports transaction: false. TransactWriteItems is used internally for single-row atomic operations; multi-row ops run sequentially in small batches.
  • Case-insensitive equality (mode: "insensitive") cannot come from an index, because DynamoDB compares keys byte-for-byte. Those clauses are matched in memory, so they need unsafeAllowScan: true unless another clause in the same where can be served by an index.
  • Better Auth's verification cleanup issues a range-only deleteMany(expiresAt < now), which has no key to work from. Configure ttl and set verification: { disableCleanup: true } — that is the DynamoDB-shaped answer to the same problem.
  • The built-in store's derived index map keys on schema field names; custom fieldName mappings are not yet resolved in derivation (pass an explicit indexMap if you rename fields).
  • Key values longer than 256 bytes are SHA-256 hashed into the key. Lookups are unaffected — both sides hash identically — but a key is no longer always readable as plain text when debugging.

License

MIT