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

better-auth-azure-cosmos

v0.5.1

Published

Azure Cosmos DB for NoSQL adapter for Better Auth.

Downloads

303

Readme

better-auth-azure-cosmos

npm version
npm downloads
Better Auth community adapter

An Azure Cosmos DB for NoSQL adapter for Better Auth.

Cosmos DB for NoSQL is not one of Better Auth's built-in adapters. This package implements the custom adapter contract on top of the @azure/cosmos SDK, including a native consumeOne so single-use tokens are genuinely atomic.

Install

npm install better-auth-azure-cosmos @azure/cosmos

better-auth (>= 1.6.0) and @azure/cosmos (^4) are peer dependencies.

Quick start

import { CosmosClient } from "@azure/cosmos";
import { DefaultAzureCredential } from "@azure/identity";
import { betterAuth } from "better-auth";
import { cosmosAdapter } from "better-auth-azure-cosmos";

const client = new CosmosClient({
  endpoint: process.env.COSMOS_ENDPOINT,
  aadCredentials: new DefaultAzureCredential(),
});

export const auth = betterAuth({
  database: cosmosAdapter(client.database("auth")),
});

Create the containers first

A partition key cannot be changed after a container is created. Use the bundled helper once, against a new database, rather than creating containers by hand:

import { ensureAuthContainers } from "better-auth-azure-cosmos";

await ensureAuthContainers(client.database("auth"));

Layouts

single-container (default)

Every model shares one container, partitioned on [/docModel, /id] (a hierarchical key). Cheapest option, and the right one when throughput is shared at the database level.

cosmosAdapter(database, {
  layout: { kind: "single-container", containerName: "auth", modelField: "docModel" },
});

modelField must not collide with a field name used by any model or plugin you enable.

container-per-model

One container per model, partitioned on /id. Mirrors the table layout of the SQL adapters and isolates throughput per model.

cosmosAdapter(database, {
  layout: { kind: "container-per-model" },
});

await ensureAuthContainers(database, {
  layout: { kind: "container-per-model" },
  models: ["user", "session", "account", "verification"],
});

Every model needs its own container, so models has to list every model your configuration touches — including the ones plugins add (organization, member, invitation, team, and so on) and any physical name you remap with modelName. A model whose container is missing fails its first read with a Cosmos 404 (Owner resource does not exist) rather than returning no rows. single-container needs none of this, because every model shares one container.

Sessions on /tokenHash

Better Auth resolves a session by its token on every authenticated request. Partitioned on /id, that lookup cannot be scoped to a partition, so the hottest read in the system fans out across all of them. Partition the session container on a stored SHA-256 of the token instead:

const layout = {
  kind: "container-per-model",
  sessionPartition: "tokenHash",
} as const;

cosmosAdapter(database, { layout });
await ensureAuthContainers(database, { layout, models: ["user", "session", "account", "verification"] });

The trade is deliberate — token-addressed reads get cheap, user-addressed ones stay unscoped:

| Operation | id (default) | tokenHash | | --- | --- | --- | | Resolve a session by token | Cross-partition | Single partition | | Update or delete by token | Cross-partition | Single partition | | List or revoke a user's sessions | Cross-partition | Cross-partition | | Read a session by id | Point read | Cross-partition |

Only the digest is stored and routed on: the raw token is a bearer credential and never becomes partition-key material. tokenHash is storage-only and never reaches Better Auth.

A token is immutable under this strategy. Changing one would move the document to another partition, which Cosmos cannot do in place, so such an update is refused rather than written back where its new token would no longer find it.

Because a partition key cannot be changed after a container is created, this must be chosen up front. An existing deployment needs a new session container rather than an in-place change — sessions are short-lived, so letting the old ones expire is usually migration enough.

Behaviour and limitations

| Capability | Status | | --- | --- | | consumeOne | Native, via an ETag (If-Match) conditional delete | | Joins | Supported, resolved as follow-up queries | | Case-insensitive matching | Supported, via STRINGEQUALS / CONTAINS / STARTSWITH / ENDSWITH | | Null equality | Matches both omitted fields and explicit JSON null | | Dates | Stored as ISO strings — Cosmos JSON has no date type | | Numeric ids | Not supported; ids are strings | | Transactions | Not supported |

Transactions. A Cosmos transactional batch is limited to a single logical partition, and every document here lives in its own. The adapter therefore reports transaction: false and Better Auth runs those operations sequentially. consumeOne is implemented natively so that single-use credentials such as magic links and OTPs stay race-safe without a transaction.

Account identity (accountPartition). Better Auth resolves an account with findAccountOwnerByKey({ providerId, accountId }). Set accountPartition: "accountKey" on the container-per-model layout to let the database enforce that pair: the account container is partitioned on /accountKeyHash (a stored sha256(providerId NUL accountId)) and created with a unique key policy on ["/providerId", "/accountId"]. Because the partition key is derived from exactly those paths, every colliding row lands in one logical partition -- the only scope a Cosmos unique key has -- so the database rejects a duplicate with a 409. Better Auth 1.7.3+ does not declare that pair unique in its core schema; declare it yourself through a plugin schema (account.indexes) if you want the startup warning to track it, and this layout is what makes the declaration true.

The trade is the same one sessionPartition makes: resolving an account by provider becomes partition-scoped, while listing or deleting a user's accounts by userId becomes cross-partition. It defaults to "id", so existing deployments are unchanged. accountKey refuses an account schema with a required issuer field, including Better Auth 1.7.0-1.7.2, because the provider-based key cannot enforce those versions' identity guarantee. Use Better Auth 1.6.x or 1.7.3+ without that legacy requirement, or choose "id" with no database-enforced account identity uniqueness. accountKey requires providerId and accountId to keep their default stored field names; a fields mapping that renames either is refused at construction.

All hash strategies require the affected model to keep its default modelName and hashed field names: account.providerId/account.accountId, session.token, and rateLimit.key. Use the layout's containerName callback to customize physical container names instead, and pass the default model names to ensureAuthContainers. The /id strategies continue to support model and field mappings. Updates, bulk updates, and incrementOne all reject changes that would move a document to another partition.

Both a partition key and a unique key policy are immutable after a container is created, so this is a decision to make before ensureAuthContainers first runs. single-container cannot enforce it at all: that layout partitions on [docModel, id], giving every row its own logical partition.

Upgrading from 0.4.x with accountKey. Versions 0.4.0–0.4.3 hashed the Better Auth 1.7.0–1.7.2 issuer field, which Better Auth removed again in 1.7.3 (see the account-schema post). An account container created by 0.4.x therefore carries accountKeyHash values and a ["/issuer", "/accountId"] unique key policy that this version does not produce. ensureAuthContainers refuses such a container, but the adapter itself does not inspect containers at runtime -- against an unconverted container every account lookup misses silently and sign-in creates duplicate users -- so run ensureAuthContainers before 0.5.0 serves traffic. Export the accounts, drop the container, let ensureAuthContainers recreate it, and re-import with the old accountKeyHash and issuer fields stripped so the new hash is stamped on write; there is no in-place migration because both the partition key and the unique key policy are immutable.

Rate limiting. Better Auth declares rateLimit.key unique and, when a create is rejected, re-reads and increments the existing row instead. Under /id nothing rejects the duplicate, so a concurrent first burst seeds one row per request and each gets its own budget -- measured as 12 concurrent requests admitting 11 against a limit of 3, across 10 rows. Set rateLimitPartition: "key" to partition that container on /keyHash with key as its unique key; the second concurrent seed is then rejected and Better Auth's own recovery path takes over. The limit check itself lives in the caller's where (count < max). incrementOne protects predicates other than a single ID equality with an If-Match ETag, whether or not set is present. On a conflict it re-reads using the complete predicate before retrying. A predicate that no longer matches returns null; repeated contention exhausts a bounded retry budget and throws instead of silently losing an increment.

incrementOne seeding. Existing numeric counters selected only by ID use atomic incr operations without a precondition. Missing or null counters start at zero and are seeded using an ETag-protected set. Concurrent first increments retry against the updated row, so they compose rather than overwrite one another. Any explicit set also uses the snapshot guard.

Cosmos permits at most ten patch operations per document. Wider incrementOne mutations use one ETag-protected document replacement with the same retry and partition checks; they are never split into separate, partially applied writes.

Uniqueness. Cosmos unique key policies are enforced within a logical partition, so a declared constraint is enforced only where the partition key is derived from exactly the constrained fields. accountPartition: "accountKey" does that for (providerId, accountId) and rateLimitPartition: "key" for rateLimit.key; no other layout enforces any declared constraint. Everything else -- user.email, session.token -- relies on Better Auth's own existence checks, and the database will not be the final arbiter of, for example, a duplicate email under a race. At construction the adapter warns, naming exactly the constraints the active layout leaves unenforced.

Query cost. Only a lookup by id is a point read. Every other where becomes a query. Under the single-container layout those queries are scoped by the model prefix of the partition key, and sessions can be scoped further — see Sessions on /tokenHash.

Testing

The suite runs against the Cosmos DB emulator:

npm run emulator:up
npm test
npm run emulator:down

Point it at a real account instead with COSMOS_ENDPOINT, COSMOS_KEY and COSMOS_DATABASE.

It runs Better Auth's own normal, authFlow, caseInsensitive and joins conformance suites, once per layout, plus routing tests that assert which queries reach a single partition. The numberId and transactions suites are intentionally not run — see the table above.

License

MIT