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

@agentoria/authkit

v1.2.0

Published

A framework-agnostic, storage-agnostic authentication toolkit built on Better Auth — email/password, phone OTP, OAuth, and RFC 8628 device authorization, with a CLI, an SDK, and a REST API. Runs on Node, Cloudflare Workers, Deno, and Bun.

Downloads

2,085

Readme

authkit

A framework-agnostic, storage-agnostic authentication toolkit built on Better Auth — email/password, phone OTP, OAuth, and RFC 8628 device authorization, with a CLI, an SDK, and a REST API. Runs on Node, Cloudflare Workers, Deno, and Bun.

Same shape as its siblings paykit and plankit: one port, several adapters, no framework dependency.

Status: 0.x — installable, not yet stable. Core, web, SDK and CLI (P1–P3 of docs/design.md §9), with one consumer. The API settles at 1.0, after a second app has had a say; until then a breaking change is a minor bump.

Install

pnpm add @agentoria/authkit better-auth

better-auth is a peer dependency, and an optional one only in the narrow sense that @agentoria/authkit/client — the fetch-and-types SDK a CLI or a browser talks to a server with — does not need it. Everything server-side does.

| import | what it is | needs better-auth | | --- | --- | --- | | @agentoria/authkit | config, cookies, sessions, schema, handler | yes | | @agentoria/authkit/web | the (Request) => Response \| null handler alone | yes | | @agentoria/authkit/schema | DDL you commit to your own migrations | yes | | @agentoria/authkit/stores/memory | tests and throwaway dev data | yes | | @agentoria/authkit/client | typed SDK, device flow included | no | | @agentoria/authkit/cli | login / logout / whoami, and the token file | no | | @agentoria/authkit/sms | routing + the phoneNumber plugin's callbacks | no | | @agentoria/authkit/sms/aliyun | Aliyun Dypnsapi, ACS3-HMAC-SHA256 signing | no | | @agentoria/authkit/sms/twilio | Twilio Verify | no | | @agentoria/authkit/oauth/wechat | 微信扫码登录 (qrconnect) | no | | @agentoria/authkit/email/templates | the auth emails, zh/en | no | | @agentoria/authkit/passwords/pbkdf2 | PBKDF2 hashing, with rehash-on-sign-in | no | | @agentoria/authkit/drizzle | does your ORM describe the database you have? | no |

/cli is the only module that touches node:fs, and it is deliberately not re-exported from the root — so import "@agentoria/authkit" stays loadable in a Worker.

The provider adapters are the one place this library implements anything rather than configuring Better Auth, and they exist because Better Auth has no opinion about Aliyun, Twilio or WeChat and never will. They plug into seams it leaves open: phoneNumber's sendOTP / verifyOTP, and the app's own mail callbacks. They came from an earlier hand-rolled authkit — see docs/design.md §3 for why its session and password code did not.

Why

Seven apps in this estate each grew their own authentication — six different session designs, with PBKDF2_ITERATIONS = 100_000, a timingSafeEqual, a sha256Hex and a sessions table re-derived independently in three or four places each.

Duplication is the cheap complaint. The expensive one is that each copy drifted differently, and the drift is where the vulnerabilities were: a stream endpoint that accepted expired and scoped tokens because it hand-rolled its own resolution; an OAuth sign-in that merged a verified identity into an account whose address had never been proved; a logout that cleared a cookie name that was never set; session tokens stored as plaintext primary keys.

authkit exists so those decisions are made once, tested once, and fixed once. docs/design.md §1 lists every defect and the design rule it argues for.

That includes upstream defaults it overrides. Better Auth stores a verification row's identifier exactly as it was sent, and that identifier is the secret half of every out-of-band flow — the token in a password-reset email, magic links, email and phone one-time codes, two-factor challenges, OIDC and MCP authorization codes. A live reset token replaces a password, so read access to that table is an account takeover needing no write, no login and no password. authkit sets verification.storeIdentifier: "hashed". Lookup falls back to the raw identifier, so links already mailed keep working and nothing needs migrating; an app that wants the upstream behaviour can still ask for verification: { storeIdentifier: "plain" }.

The same applies to what a reset does. Upstream leaves revokeSessionsOnPasswordReset off, so completing one gives the account a new password while whoever was already signed in stays signed in — and a reset is usually how somebody tries to evict them. authkit turns it on wherever an app configures emailAndPassword, and an app that wants otherwise can say so. Its sibling stays the caller's: changePassword takes revokeOtherSessions in the request body, which no library default can supply.

Use

import { createAuthkit, createAuthHandler, schemaSQL } from "@agentoria/authkit";

const auth = createAuthkit({
  secret: env.AUTH_SECRET,      // required; keys the HMAC over the cookie
  baseURL: "https://app.example.com",
  database: env.DB,             // a D1 binding, a pg Pool, node:sqlite, …
});

// Compose it: returns null for requests that are not auth's, so the host
// keeps its own routes. Same shape as paykit and plankit.
const handler = createAuthHandler(auth, { basePath: "/api/auth" });
export default {
  fetch: async (request) => (await handler(request)) ?? myRouter(request),
};

Tables come as SQL you commit, not as a tool that reaches into your database:

console.log(schemaSQL(auth, "sqlite"));   // paste into your next migration

schemaSQL reads the configured instance, so enabling a plugin adds its tables to the output. Regenerate after changing plugins.

Revocation is named rather than inferred — revokeSession, revokeOtherSessions, revokeAllSessions. Clearing a cookie is not signing out; it removes the browser's copy of a credential that still works.

Signing someone in without a password

An OAuth callback that verified a profile, an admin-key exchange, an invitation link, a test fixture — each holds a user id and needs the session that would have followed:

import { startSession } from "@agentoria/authkit";

const { value, setCookie, sessionId } = await startSession(auth, user.id);

All three describe one session, from one mint. value goes in a Cookie header, setCookie is a ready Set-Cookie, sessionId answers "which session is this" without recomputing it.

Pass where the sign-in came from and the session records it, so an account's security page can name the device and address instead of showing blanks:

await startSession(auth, user.id, { ipAddress: clientIp(request), userAgent });

Values, not headers: every app here already resolves the client address for rate limiting, and re-deriving it inside authkit would give one app two answers about who called. advanced.ipAddress.disableIpTracking still wins — an app that has promised not to store addresses does not start because a caller passed one.

The stored token is deliberately not returned. The cookie is an HMAC over it, which is what stops a dumped session row from being replayable — so handing back both would make presenting the wrong one an ordinary slip, and that slip turns a database read into a session.

A link that works once

A password-reset link, an email-change confirmation, an invitation — mint an unguessable string, mail it, redeem it exactly once before it expires:

import { issueOneTimeToken, redeemOneTimeToken } from "@agentoria/authkit";

const token = await issueOneTimeToken(auth, {
  kind: "password-reset",
  value: String(user.id),
  ttlMinutes: 60,
});
// …mail it, then, when the link is opened:
const userId = await redeemOneTimeToken(auth, { kind: "password-reset", token });

kind is part of the stored identifier, so a token minted for one purpose cannot be redeemed for another — an email-change token names an address its holder proved they control, and honouring it as a password reset would hand them that account. null covers every failure (unknown, spent, expired, wrong kind) with one answer, because telling a holder which one it was tells whoever is guessing how close they are.

This is storage, not a flow. Better Auth's requestPasswordReset also decides how the email reads, which sessions to revoke and how the reset is rate-limited; an app that already has answers to those keeps them and stops keeping its own auth_tokens table. Redemption is atomic — two requests racing for one token produce one winner, checked against a real database rather than assumed.

From a CLI

The device flow (RFC 8628), for anything without a browser:

import { login, logout, whoami } from "@agentoria/authkit";

await login({ baseURL: "https://app.example.com" });
//  Open https://app.example.com/api/auth/device?user_code=BCKLFB4K
//  and enter the code: BCKLFB4K
//  Waiting for approval…
//  Signed in.

The token is stored 0600 and logout ends the session server-side before removing it — deleting the file only removes this machine's copy of a credential that still works everywhere else.

bearer is installed by default, and that is not cosmetic: without it Better Auth ignores Authorization: Bearer entirely, so the device flow completes, prints success, and every later call is anonymous. Plugins you pass are added to the defaults rather than replacing them, so you cannot lose it by accident.

After adopting into an app that already has a users table

Pointing Better Auth at your existing tables gives each of them a third writer — Better Auth's adapter, your ORM, and your migrations — each carrying its own description of the shape, with nothing reconciling them. On SQLite a disagreement is not an error: it is a value that reads back cleanly and wrongly. Your types cannot catch it (the ORM declaration is what they check against) and neither can your suite (the fixtures write through that same declaration).

So derive both sides and compare them, once:

import { describeDrift, schemaDrift } from "@agentoria/authkit/drizzle";
import * as schema from "../src/db/schema";

test("the schema declares the database the migrations produce", async () => {
  const drift = await schemaDrift(schema, (sql) => db.prepare(sql).all());
  expect(describeDrift(drift)).toBeNull();
  // A check that examined nothing is also clean. Rename the schema file and the
  // first line goes on passing for as long as anybody cares to look.
  expect(drift.comparedTables.length).toBeGreaterThan(20);
});

That one assertion is what catches the migration that converted created_at to TEXT while the schema file still says integer(mode: "timestamp") — the shape of failure this hits first, and the one that reads back as Invalid Date rather than as an error. drizzle-orm is an optional peer; nothing else here needs it.

Not every disagreement is a defect. SQLite's declared type is an affinity, not a constraint, so a column declared INTEGER holding ISO-8601 text behaves exactly as TEXT would — and converting the rows without rebuilding the table (which means dropping and recreating every inbound foreign key) is often the right call. Say so, and say what the database has:

const drift = await schemaDrift(schema, read, {
  // 0014 converted every row to ISO-8601 text and left the column INTEGER:
  // rebuilding `users` would mean dropping eight inbound foreign keys.
  accept: { "users.created_at": "integer" },
});

The value is what the database has, so this accepts one specific disagreement rather than switching a column off. A later migration that makes it REAL is a new fact and fires again; one that finally converts it to TEXT reports the entry as stale, so the exemption cannot outlive its reason.

Scope

In: email + password, phone OTP, OAuth (GitHub / Google / generic OIDC), sessions and revocation, API keys, device authorization, and the three surfaces every app needs — REST, SDK, CLI.

Out: authorization (roles, teams, entitlements stay in the apps and in plankit), non-TypeScript apps, and anything rendered — login forms belong to the app's design system.

License

MIT