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

@bjorntech/betterauth-dynamodb

v1.1.0

Published

Production-oriented Better Auth DynamoDB adapter

Readme

@bjorntech/betterauth-dynamodb

Production-oriented DynamoDB adapter targeting Better Auth ^1.6.25 via the official createAdapterFactory API.

Status

This package is published on npm as @bjorntech/betterauth-dynamodb. The repository has unit coverage for command construction, adapter semantics, query planning, and Better Auth factory wiring, plus explicit Docker-backed DynamoDB Local integration suites (including a separate Better Auth adapter conformance test file).

Storage compatibility note: the current storage format uses transactionally maintained scalar equality sidecar rows in the base table, delimiter-safe length-prefixed key components, SHA-256 hashes for sidecar/unique-lock values, and hidden internal revision metadata for ABA-safe mutations. Experimental tables using older generic gsi1/idx_<field>_*, pre-length-prefixed, pre-hash, or pre-revision formats should be recreated or migrated before using this version; pre-revision rows can still be read but fail clearly if mutated.

Upgrade note: 1.0.1 to 1.1.0 requires no migration when enforceSchemaUniqueIndexes remains at its default false, and IN support requires no migration. For existing records, opting into new schema unique constraints requires a stopped-write audit, duplicate repair, and application-owned lock backfill before all compatible writers restart with enforcement enabled. New empty affected models need no backfill. No migration utility is supplied.

Installation

Install the adapter and its Better Auth peer dependency with your package manager:

npm install @bjorntech/betterauth-dynamodb better-auth
bun add @bjorntech/betterauth-dynamodb better-auth
pnpm add @bjorntech/betterauth-dynamodb better-auth

AWS SDK DynamoDB packages are runtime dependencies of this adapter.

Basic usage

Prefer injecting a DynamoDBDocumentClient so your application owns AWS configuration, credentials, middleware, tracing, and marshalling behavior:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { betterAuth } from "better-auth";
import { dynamoDBAdapter } from "@bjorntech/betterauth-dynamodb";

const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
  marshallOptions: { removeUndefinedValues: true }
});

export const auth = betterAuth({
  database: dynamoDBAdapter({
    tableName: "better-auth",
    client: dynamo,
    ttl: { fields: { session: "expiresAt", verification: "expiresAt" } }
  }),
  verification: { disableCleanup: true },
  emailAndPassword: { enabled: true }
});

When the verification model uses adapter-managed TTL, disable Better Auth's verification cleanup as shown above. Otherwise Better Auth can issue a range-only deleteMany such as expiresAt < now, which this adapter rejects by default because it would require a hidden table/model scan.

For local development you may pass region, endpoint, and/or dynamoDBClientConfig instead of client. Production deployments should usually inject the client.

See examples for standalone deployment examples that use local path dependencies.

API

import { dynamoDBAdapter } from "@bjorntech/betterauth-dynamodb";

dynamoDBAdapter(options) accepts:

  • tableName: string - required DynamoDB table name.
  • client?: DynamoDBDocumentClient - preferred production integration path.
  • region?: string, endpoint?: string, dynamoDBClientConfig?: DynamoDBClientConfig - used only when this package creates the AWS client.
  • ttl?: false | { attributeName?: string; fields?: Record<string, string>; defaultField?: string } - derives a DynamoDB TTL epoch-seconds attribute from Better Auth date fields. When omitted or false, logical TTL filtering is inactive and a user/plugin field named ttl remains ordinary visible data.
  • unsafeAllowScan?: boolean - defaults to false; must be explicitly enabled for scan-shaped query/update/delete/count operations.
  • maxPages?: number - positive safe integer maximum DynamoDB Query/Scan pages drained for sidecar equality lookups and explicit unsafe scans; defaults to 25. If DynamoDB still has more pages at this cap, the adapter throws rather than returning incomplete filtered/windowed results.
  • pageSize?: number - optional positive safe integer DynamoDB Limit for each Query/Scan request. Pagination is still fully drained up to maxPages, so this controls request sizing and does not change the final logical result. It is mainly useful for testing pagination and tightly bounded local workloads; leave unset in normal production use unless you have measured the throughput/latency trade-off.
  • uniqueFields?: Record<string, string[]> - optional manual unique-field configuration merged with Better Auth schema unique fields. Keys and fields must use the exact DynamoDB/storage names the adapter receives after Better Auth modelName/fieldName mapping (for example { app_user: ["email_address"] }, not { user: ["email"] }, when those mappings are configured). Each listed field is an independent single-field unique constraint; composite uniqueness is not supported by this option.
  • enforceSchemaUniqueIndexes?: boolean - opt-in enforcement of unique Better Auth schema indexes; defaults to false. Unique index fields follow Better Auth's own rules: 1–16 distinct required fields, excluding json, string[], and number[] types (enum-literal string types are allowed). Existing rows are not backfilled automatically and pre-existing records receive no retroactive locks; activate only after an application-owned duplicate audit and compatible lock backfill.
  • maxBulkConcurrency?: number - positive safe integer concurrency cap for independent updateMany/deleteMany transactions; defaults to 8. A failure can follow successful mutations, so bulk operations are not aggregate-atomic and must not be blindly retried.

The schema-index option consumes Better Auth's actual table-level indexes array (DBTableIndex[]) and only acts on entries with unique: true; it does not add an alternative schema-definition option. On a new empty table it can be enabled directly. For a populated table, use a maintenance window with writes stopped: audit duplicates, repair them, and complete an application-owned lock backfill before restarting all writers with compatible code and enforcement enabled. This package provides no migration or backfill utility; do not enable it on unbackfilled rows or allow old/incompatible writers to continue. The UNIQUE2 namespace is stable only while the index name (explicit or derived), ordered field list, and model/field mapping remain unchanged; changing any of those requires backfilling the new namespace, and pre-existing records do not acquire locks automatically. Rollback requires an explicit plan for versioned locks and writers that may have observed the constraint; disabling the option alone does not undo backfill or repair duplicates.

The package also exports BetterAuthDynamoDBOptions, TtlOptions, DynamoDBAdapterError, and UnsupportedQueryError.

Deployment notes

Keep table ownership in your application infrastructure and inject a DynamoDBDocumentClient into the adapter. Provision a DynamoDB table with pk as the partition key, sk as the sort key, and DynamoDB TTL enabled on the adapter TTL attribute when you configure adapter-managed TTL. No GSIs are required for the current sidecar-index design.

For email OTP sign-in or other flows where verification rows expire through DynamoDB TTL, keep verification.disableCleanup: true in the Better Auth config. Without it, Better Auth attempts to clean verification rows with a range-only deleteMany(expiresAt < now), and the adapter rejects that no-key access pattern under its no-hidden-scan policy.

Better Auth database-backed rate limiting has the same DynamoDB trade-off. The rate-limit key field is schema-unique and works well for exact-key increments, but Better Auth cleanup can require range-only predicates such as old lastRequest values. Leave unsafeAllowScan disabled for production unless you have a tightly bounded table and have accepted the cost/consistency profile. Prefer Better Auth's non-database/in-memory limiter for single-instance local development, an edge/API-gateway/WAF limiter, or an application-owned DynamoDB rate-limit table with access patterns designed for your cleanup needs.

If a needed access pattern has no id or scalar equality predicate, the adapter throws unless unsafeAllowScan: true is set. OR predicates and mode: "insensitive" equality also cannot use keyed access safely: OR can match rows outside one narrowed key branch, and DynamoDB entity/sidecar keys are case-sensitive. Those shapes require unsafeAllowScan: true so the adapter can explicitly scan the bounded model partition and evaluate the full expression in memory. A future optional native-GSI optimization may be added with descriptive API names, but there is no generic GSI option today.

Table and key model

  • Table primary key: pk = MODEL#s<byteLength>:<model>, sk = ID#s<byteLength>:<id>. Model, field, and id components use deterministic length prefixes so embedded delimiters such as # cannot collide across entity, sidecar, or unique-lock rows.
  • The Better Auth-visible record is stored in entity and scalar fields are duplicated at top level for conditional checks and scan-opt-in filtering.
  • Before writes, undefined values are removed from plain object records and arrays because DynamoDB has no undefined value type. Array entries containing undefined are omitted, which compacts arrays. Non-plain values such as Date, binary values, sets, and class instances are preserved unchanged; if a preserved custom instance is not marshalable by your injected AWS SDK client, normalize it to a plain supported value before passing it to Better Auth.
  • Scalar equality sidecars are stored in the same table: pk = INDEX#s<byteLength>:<model>#FIELD#s<byteLength>:<field>#VALUE#<sha256(typed-value)>, sk = OWNER#s<byteLength>:<id>. The typed value encoding (before hashing) preserves distinctions such as string "1", number 1, boolean true, dates, and null, and supports arbitrary Better Auth/plugin scalar fields without one GSI per field. Owner rows are always re-read and checked against the original typed predicate, so the hash is only a bounded DynamoDB key component.
  • Unique fields declared by Better Auth schema, or by uniqueFields, create transactional lock rows: pk = UNIQUE#s<byteLength>:<model>#s<byteLength>:<field>, sk = VALUE#<sha256(typed-value)>#s0:.
  • When enforceSchemaUniqueIndexes is enabled, each unique Better Auth schema index adds a UNIQUE2 compound lock row (a storage-format addition; existing entity, scalar-sidecar, and single-field unique-lock rows are unchanged): pk = UNIQUE2#s<byteLength>:<model>#s<byteLength>:<index-namespace>, sk = TUPLE#<sha256(type-prefixed tuple)>. Components use delimiter-safe length prefixes, and tuple values are type-prefixed before hashing. An explicit index name is the namespace. Without one, the namespace is the ordered, model-remapped field list encoded as s<UTF-8 byte length>:<field> components joined by #, then length-prefixed again by compoundUniquePk; declared field order is preserved. For example, fields email, tenantId and values "[email protected]", "acme" produce pk = UNIQUE2#s4:user#s20:s5:email#s8:tenantId and sk = TUPLE#7a2e1d8fc9815968b003de5dfc1ce4e013de254c811f4566bfaef8dc444963fa. A null or undefined tuple component skips the lock; an empty string is a real value. Renaming an index or changing its field list or order changes its namespace, so existing locks must be re-backfilled; pre-existing records receive no retroactive locks.
  • Do not bypass this adapter with direct DynamoDB writes for Better Auth records. Entity rows, scalar sidecars, unique locks, TTL attributes, and hidden revision metadata must be created, replaced, and deleted together; out-of-band writes can break uniqueness, query results, logical expiration, or optimistic mutation guards.
  • Entity rows include a reserved top-level __betterAuthDynamoDBRevision UUID. It is generated on create and refreshed on each replacement/update/increment, used only for optimistic write guards, and never returned as Better Auth-visible metadata.
  • DynamoDB partition keys are validated against the 2048-byte UTF-8 limit and sort keys against the 1024-byte UTF-8 limit. Long scalar field values are hashed in sidecar and unique-lock keys to avoid partition/sort key overflow; long model, field, or id components fail with an actionable error.
  • TTL is derived from configured date fields into a DynamoDB TTL attribute (default ttl) and is not added to the Better Auth-visible entity. Entity, scalar sidecar, and unique-lock rows receive compatible TTL metadata when available. Reads treat configured TTL as logical expiration: an owner row at or before the current epoch second is hidden for id, sidecar, and explicit scan paths even if DynamoDB has not physically deleted it. Sidecars/locks may therefore expire with their owner; stale sidecars still cannot be returned because the owner entity must exist, be unexpired, and match the requested typed value. When ttl is omitted or false, this logical expiration path is completely disabled; use attributeName if you configure TTL and also need a user/plugin field named ttl at top level.
  • Expired compound locks are logically ignored for reads, but reuse can still wait for DynamoDB's physical TTL deletion. Do not treat logical expiration as immediate physical lock removal.

Concurrency guarantees and limitations

  • create transactionally writes the entity row, scalar equality sidecars, and configured uniqueness locks with conditional non-existence checks.
  • update, updateMany, delete, and deleteMany transactionally maintain sidecars and uniqueness locks. After the adapter reads and matches a target, the write uses an optimistic condition on the hidden per-entity revision rather than trying to translate arbitrary Better Auth operators into DynamoDB transaction conditions. This rejects stale ABA-shaped writes even if record contents changed away and back between read and mutation.
  • consumeOne transactionally deletes the matched entity, sidecars, and uniqueness locks with the same revision guard. Exactly one concurrent caller can consume a row; normal stale/lost consume races resolve to null as Better Auth expects, while non-conditional AWS failures still throw.
  • incrementOne uses a DynamoDB transactional Update with native numeric ADD for numeric/missing counter attributes plus the same revision guard, while maintaining scalar sidecars and uniqueness locks in the same TransactWriteItems request. Signed and zero finite deltas are supported; existing non-number counter values follow Better Auth's fallback semantics and are treated as zero with a guarded SET instead of invalid DynamoDB ADD. The returned row is the updated Better Auth-visible entity computed from the guarded snapshot; if the target guard no longer matches, the operation returns null. Unique-lock conflicts or non-conditional AWS failures still throw.
  • Transactional writes include a fresh AWS ClientRequestToken per command construction. This gives AWS SDK/internal network retries of that single send a stable token, but it is not cross-invocation or application-level idempotency.
  • DynamoDB TransactWriteItems is limited to 100 actions. The adapter de-duplicates configured unique-field lists, validates that a transaction contains at most one action per item key, rejects serialized transaction requests over approximately 4 MB, and fails early with an actionable error if entity + sidecars + unique locks for a mutation would exceed either limit.
  • Better Auth callback transactions are unsupported because DynamoDB has no interactive transaction API; the adapter reports transaction: false. DynamoDB TransactWriteItems is single-shot and is used internally for single-record atomic operations.
  • Queries without id equality or scalar equality require unsafeAllowScan: true; this avoids accidental full-table/model draining. Queries containing OR predicates, or equality clauses with mode: "insensitive" and no other safe keyed equality, also require explicit unsafe scans. updateMany, deleteMany, and count obey the same guard.
  • Base entity gets, sidecar queries, owner gets, and explicit unsafe scans request ConsistentRead: true. Strongly consistent reads cost more RCUs than eventually consistent reads. DynamoDB still does not provide a whole-operation snapshot for a paginated scan, so explicit unsafe scans can observe changes between pages.
  • Scalar equality queries use paginated base-table Query calls on the sidecar partition, then retrieve owner entity rows and apply residual where, sorting, offset, limit, and count in memory. Explicit unsafe scans are also paginated before in-memory filtering/windowing. Keep high-cardinality equality partitions and scan-enabled workloads bounded, and tune maxPages for your data shape; if the page cap is reached before LastEvaluatedKey clears, the adapter throws instead of returning a partial result. pageSize can force smaller Query/Scan pages when you intentionally want more, smaller requests.
  • Scalar IN predicates on id or a scalar field use one keyed lookup per distinct candidate value, issued sequentially so the shared page budget stays deterministic; a 1,000-value IN therefore costs roughly 1,000 sequential DynamoDB round-trips before owner reads. Keep IN lists small in latency-sensitive paths. Empty lists are no-ops; duplicate values are removed; more than 1,000 distinct values are rejected before reads. Sidecar queries share one global maxPages budget, owner reads are bounded by maxBulkConcurrency, and residual filters run before global sorting, offset, and limit windowing. Case-insensitive IN cannot use exact-case indexes as a complete access path: it requires an independent safe equality anchor or explicit unsafeAllowScan: true; with an anchor it is evaluated as a residual predicate. Case-insensitive IN/not_in comparisons normalize array members.
  • updateMany and deleteMany use bounded concurrency (controlled by maxBulkConcurrency, default 8) and one revision-protected transaction per matched record. On failure they stop claiming new records and wait for started workers; earlier commits are not rolled back. They return numeric affected counts and are not aggregate-atomic or safe for blind retries. Each transaction has a 100-action and approximate JSON/practical byte-size preflight; AWS remains the ultimate size authority.
  • Joins use Better Auth's fallback behavior by default: Better Auth performs separate findOne/findMany calls, which remain subject to this adapter's no-hidden-scan policy. Better Auth's native-join path (advanced.database.joins on 1.7.x, experimental.joins on 1.6.x) is not supported because DynamoDB cannot honestly join arbitrary Better Auth models without explicit access-pattern design; if enabled and Better Auth passes a native join to the adapter, the adapter throws UnsupportedQueryError with instructions to disable the option.
  • The local integration suite uses AWS's official amazon/dynamodb-local:2.6.1 image through Testcontainers. It proves command behavior against a real DynamoDB API surface for table keys, conditional transactions, sidecar/unique-lock persistence, logical TTL filtering, pagination tokens, and injected clients. The separate test/integration/betterauth-conformance.test.ts also runs Better Auth's adapter conformance suites and enables unsafeAllowScan because the canonical suites intentionally exercise scan-shaped predicates; production defaults still reject those shapes unless explicitly opted in. These tests do not prove IAM, global tables, streams, PITR/backups, encryption, adaptive capacity, throttling behavior, CloudWatch metrics, real service TTL deletion timing, or every production transaction-conflict edge; validate those in AWS for production-critical rollouts.

Verification, coverage, and CRAP policy

bun run verify
bun run test:integration:local

bun run verify runs typecheck, ESLint, coverage tests, CRAP check, and build. The package is tested against Better Auth 1.7.5 and requires Better Auth ^1.6.25 as a peer dependency. bun run test:integration:local starts DynamoDB Local in Docker using a random mapped port, fake credentials, in-memory shared DB mode, telemetry disabled, and isolated tables for the local integration and separate adapter conformance suites.

Provider coverage uses the published @better-auth/oauth-provider 1.7.5 plugin schema and direct query-shape tests, plus ordinary Better Auth HTTP authentication flows. It does not include the provider's full OAuth endpoint surface.

Coverage thresholds are enforced for production src code (excluding pure types). CRAP is computed per production function as:

CRAP = complexity^2 * (1 - lineCoverage)^3 + complexity

The verification command fails if any implementation function has CRAP > 6. Do not lower coverage thresholds or exclude production implementation files to bypass this gate.

Scripts

  • bun run typecheck
  • bun run lint
  • bun run test
  • bun run test:coverage
  • bun run test:integration:local
  • bun run quality:crap
  • bun run build
  • bun run smoke:dist-import
  • bun run verify
  • bun run verify:integration

Contributing

See CONTRIBUTING.md. Contributions should preserve atomic correctness, avoid hidden scans, maintain injected-client compatibility, and keep the CRAP <= 6 gate passing.

License

MIT © 2026 BjornTech AB. See LICENSE.