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

@ingram-tech/nk-auth

v0.12.2

Published

The Ingram Better Auth foundation: composable presets (org, dual-shape JWT, active-org hooks, pg pool) for Next.js sites.

Readme

@ingram-tech/nk-auth

The Ingram Better Auth foundation: a toolkit of composable presets each Ingram Next.js site spreads into its own betterAuth() call. It is not a betterAuth() wrapper — the site stays plain Better Auth (prime directive), keeping full plugin type inference. Import only what you need from focused subpaths.

better-auth, pg, @better-auth/passkey are peer dependencies so there's exactly one Better Auth copy in the app.

| Export (subpath) | For | | --- | --- | | authEnv, authSecret, isConfigured (./) | env validation — authEnv() returns the full { secret, baseURL, databaseUrl } bundle; authSecret() resolves just the secret (with the prod/dev rule) for sites that derive their own baseURL / DB connection | | backendJwtOptions / verifyBackendJwt (./jwt) | a JWT for the site's own backend API (custom audience) | | nkOrganizationDefaults, lastActiveOrganizationHooks, lastActiveOrganizationUserField (./organization) | org-plugin defaults + active-org restore/persist | | createAuthPool (./pool) | deprecated — alias of createPool from @ingram-tech/nk-db; inject the site's shared pool instead | | makeEmailSenders, makePasskeyOptions, passkeyOptionsForBaseUrl, uuidGenerateId (./) | email hooks, passkeys (passkeyOptionsForBaseUrl derives rpID/origin from a single base URL), UUID ids | | bcryptPassword (./) | legacy only — bcrypt verifier for sites with pre-existing bcrypt hashes. New sites omit it (Better Auth defaults to scrypt). See Migrating bcrypt passwords to scrypt | | createAuthHelpers, safeNext (./server) | validated App Router session helpers (getSession / getUser / requireSession / requireUser / redirectIfAuthenticated), request-memoized via React cache(), with automatic next + stale-cookie signalling; safeNext validates a ?next= param | | createAuthMiddleware (./middleware) | loop-safe edge middleware: gates unauthenticated users off protected paths, preserves next, and clears a stale session cookie so a bad session self-heals |

Data access + RLS is owned by @ingram-tech/nk-db: query over a direct pg connection and enforce per-request Row Level Security with withRls / withRlsTransaction (claims taken from the Better Auth session — no JWT minting, no REST proxy).

Backend-JWT + org sites (a backend API plus the org plugin): compose the site's shared nk-db pool, backendJwtOptions({ audience }), nkOrganizationDefaults, and lastActiveOrganizationHooks(pool) in your betterAuth(); verify backend tokens with verifyBackendJwt. Keep app-specific bits (SSO restrictions, permissions/roles, connectors) in the app.

Install

bun add @ingram-tech/nk-auth better-auth @better-auth/passkey pg bcrypt

Set the env contract (validated by keys.ts):

BETTER_AUTH_SECRET=…            # openssl rand -hex 32
BETTER_AUTH_URL=https://example.com
DATABASE_URL=…                  # direct Postgres connection (:5432)

Outside production, BETTER_AUTH_SECRET falls back to a well-known insecure placeholder, so local dev and tests run without setting it (a warning is logged). In production it stays required — a missing secret throws at startup.

If your site derives its own baseURL and opens its own database connection, it may not want authEnv()'s all-or-nothing bundle (which also requires BETTER_AUTH_URL and DATABASE_URL). Take just the secret — same prod/dev rule — with authSecret():

import { authSecret } from "@ingram-tech/nk-auth";

export const auth = betterAuth({
	database: myOwnDirectPool,
	secret: authSecret(), // required in prod, dev placeholder otherwise — owned here
	baseURL: myOwnBaseUrl,
	// ...
});

Do this instead of re-implementing the prod-required / dev-placeholder rule in the app: the security-sensitive default then lives in exactly one place.

1. Apply the schema

cp node_modules/@ingram-tech/nk-auth/migrations/0001_better_auth.sql \
   migrations/$(date +%Y%m%d%H%M%S)_better_auth.sql

It creates Better Auth's tables (user, session, account, verification, jwks, passkey), defaults new user ids to UUIDs, and puts deny-all RLS on all of them (Better Auth reaches them through its own privileged connection). Reconcile against your pinned better-auth with npx @better-auth/cli generate after upgrades.

2. Configure the server

This package is not a betterAuth() wrapper — your site calls betterAuth itself and spreads in our presets. That keeps full Better Auth type inference at the call site (so auth.api.* stays typed) and keeps the site plain Better Auth, per the prime directive.

// lib/auth.ts
import { passkey } from "@better-auth/passkey";
import { fromAddress, sendEmail } from "@ingram-tech/nk-email";
import {
	authBasePath,
	authEnv,
	makeEmailSenders,
	passkeyOptionsForBaseUrl,
	uuidGenerateId,
} from "@ingram-tech/nk-auth";
import { betterAuth } from "better-auth";
import { pool } from "@/lib/db"; // the ONE shared createPool() from @ingram-tech/nk-db

const env = authEnv();
const email = makeEmailSenders(({ to, subject, url }) =>
	sendEmail({ to, from: fromAddress(), subject, text: url, html: url }),
);

export const auth = betterAuth({
	database: pool, // inject the shared pool — exactly one pool per process
	secret: env.secret,
	baseURL: env.baseURL,
	basePath: authBasePath, // mount at /auth, not the framework default /api/auth
	advanced: { database: { generateId: uuidGenerateId } }, // UUIDv7 ids
	// ^ stored as hyphenated UUIDv7 (uuid columns / RLS policies stay valid).
	// To show those same ids as prefixed base58 on the wire/UI — `team_…`,
	// matching the Ingram Cloud API's `agt_`/`smt_` ids — skin them with
	// `toPrefixedId(uuid, "team")` / recover with `fromPrefixedId`. `base58Id`
	// mints a fresh one directly for text-id sites. All from `@ingram-tech/nk-auth`.
	emailAndPassword: {
		enabled: true,
		// Better Auth hashes with scrypt by default. Only sites carrying
		// pre-existing bcrypt hashes set `password: bcryptPassword` (legacy).
		sendResetPassword: email.sendResetPassword,
	},
	emailVerification: { sendVerificationEmail: email.sendVerificationEmail },
	socialProviders: {
		google: {
			clientId: process.env.GOOGLE_CLIENT_ID ?? "",
			clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
		},
	},
	plugins: [
		// Single sign-in origin: derive rpID (host) + origin from the base URL.
		// For multi-origin / parent-domain sites use makePasskeyOptions({ rpId,
		// rpName, origin }) and pass the registrable domain explicitly.
		passkey(passkeyOptionsForBaseUrl(env.baseURL, "Example")),
	],
});
// app/auth/[...all]/route.ts — a standard Next.js route handler.
// Lives at /auth (set via `basePath: authBasePath`), NOT /api/auth: auth is a
// user-facing surface (sign-in, OAuth callbacks), not an internal machine API.
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);

3. Query data with RLS intact

Data access lives in @ingram-tech/nk-db, not here. Query over the direct pg connection and wrap reads/writes in withRls / withRlsTransaction, which set request.jwt.claims + SET LOCAL ROLE per transaction from the Better Auth session — so auth.uid() policies fire unchanged, with no JWT minting and no REST proxy. See its README for the pattern.

4. Client

Assemble the client in a "use client" module (full plugin inference is preserved here too):

"use client";
import {
	authBasePath,
	createAuthClient,
	jwtClient,
	passkeyClient,
} from "@ingram-tech/nk-auth/client";

export const authClient = createAuthClient({
	baseURL: process.env.NEXT_PUBLIC_SITE_URL ?? "",
	basePath: authBasePath, // matches the server: /auth
	plugins: [jwtClient(), passkeyClient()],
});
// authClient.signIn.email(...), signIn.social(...), useSession(), passkey.*

5. Gate routes (without the redirect loop)

Two layers, one rule that keeps them from fighting: only the validated layer may redirect a request away from the sign-in page.

The validated layer (server helpers) — bind once to your instance:

// lib/auth/session.ts
import { createAuthHelpers } from "@ingram-tech/nk-auth/server";
import { auth } from "@/lib/auth";

export const {
	getSession,
	getUser,
	requireSession,
	requireUser,
	redirectIfAuthenticated,
	getLinkedProviders, // providerIds linked to the current user
	hasCredentialAccount, // does the current user have an email/password login?
} = createAuthHelpers(auth);
// app/dashboard/page.tsx — gate a protected page (validated, DB-backed).
import { requireUser } from "@/lib/auth/session";
export default async function Dashboard() {
	// -> /login?next=/dashboard when signed out (and ?stale=1 when the cookie is
	// present-but-invalid, so middleware clears it). next/stale are automatic.
	const user = await requireUser();
	return <main>Hi {user.email}</main>;
}

// app/login/page.tsx — gate the sign-in page HERE, never in middleware. Honor
// `next` so sign-in returns the user to where they were headed.
import { redirectIfAuthenticated } from "@/lib/auth/session";
import { safeNext } from "@ingram-tech/nk-auth/server";
import { LoginForm } from "./login-form";
export default async function Login({
	searchParams,
}: {
	searchParams: Promise<{ next?: string }>;
}) {
	const next = safeNext((await searchParams).next) ?? "/dashboard";
	await redirectIfAuthenticated(next); // validated: a stale cookie resolves to
	return <LoginForm next={next} />; // "signed out" and falls through to the form
}

The optimistic layer (middleware) is a fast edge short-circuit on cookie presence. It can save a render for users with no cookie at all — but it must never decide the sign-in page, because a present-but-invalid cookie there is exactly what loops. createAuthMiddleware enforces that at construction: it throws if you try to protect or front-door the sign-in path.

// middleware.ts
import { createAuthMiddleware } from "@ingram-tech/nk-auth/middleware";

export const middleware = createAuthMiddleware({
	protectedPaths: ["/dashboard", "/memory"], // cookie-less -> signInPath
	signInPath: "/login",
	frontDoorPaths: ["/"], // optional: cookie-bearing "/" -> signedInRedirect
	signedInRedirect: "/dashboard",
});

export const config = {
	matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)"],
};

Why the split: middleware runs before render and can't afford a DB lookup, so it can only trust the cookie exists. The server helpers hit auth.api.getSession and check the session contents (once per request — the read is memoized with React cache()). When those disagree (revoked session, rotated secret, wiped DB) the validated layer wins and parks the user on /login — and because middleware refuses to bounce /login, the form renders instead of ping-ponging.

Self-heal. A bad session doesn't strand the user. The guard redirects to /login?next=<where they were>&stale=1; middleware (the only pre-render place that can touch cookies) deletes the dead Better Auth cookies on the stale marker and bounces to a clean /login?next=…; signing in returns them to next. next for the cookie-less case is filled in by middleware directly; for the cookie-present case the guard reads it from the x-nk-auth-path header middleware injects — so the self-heal (next + clearing) needs the middleware. Sites that skip middleware still get validated gating from the server helpers, just without automatic next/clearing.

6. Passwords: change, set, and reset

nk-auth owns the reset sender (makeEmailSenders.sendResetPassword, §2) and now closes the loop so a site never touches Better Auth's account table or endpoint names directly. Three pieces:

Detect what login the user has. A social-only account (Google, …) has no password credential until it sets one. hasCredentialAccount() drives the "Change password" vs "Set password" choice on a security page; reach for getLinkedProviders() when you need the full list.

// app/settings/security/page.tsx (server component)
import { hasCredentialAccount } from "@/lib/auth/session";
export default async function Security() {
	return (await hasCredentialAccount()) ? <ChangePassword /> : <SetPassword />;
}

Set a password without a current one. A social-only user has no current password to verify, so the re-auth is an emailed reset link — clicking it proves account ownership. Trigger it with the standard client call; there is no separate "set password" endpoint:

await authClient.requestPasswordReset({
	email,
	redirectTo: "/reset-password", // your token-consumer page (below)
});

Better Auth's reset endpoint creates the credential when the user has none, so the same flow both resets a forgotten password and sets a first one. That guarantee is pinned by reset-password.test.ts against a real instance, so a Better Auth upgrade can't silently break the set-password path.

Consume the token. The email link lands on your page with ?token= (or ?error=INVALID_TOKEN). useResetPassword is the headless state machine — invalid-token, submitting, success, and policy validation (length + match) — against the shared passwordSchema bounds. You bring the shell:

"use client";
import { useResetPassword } from "@ingram-tech/nk-auth/client";
import { authClient } from "@/lib/auth/client";

export function ResetPasswordForm({ token }: { token: string | null }) {
	const { status, error, submit } = useResetPassword(authClient, { token });
	if (status === "invalid") return <p>This link is invalid or has expired.</p>;
	if (status === "success") return <p>Password set. You can now sign in.</p>;
	// <form onSubmit={() => submit(newPassword, confirm)}>; render `error.code`
	// (stable, for i18n) or `error.message` (English fallback).
}

Policy constants live at @ingram-tech/nk-auth/password (pure, importable from both ends): DEFAULT_MIN_PASSWORD_LENGTH / DEFAULT_MAX_PASSWORD_LENGTH, passwordSchema(), and validateNewPassword(). Pass your resolved bounds to useResetPassword({ token, minLength, maxLength }) if you override Better Auth's defaults, so the form and the server never drift.

.well-known/change-password. Add the W3C well-known redirect so password managers deep-link straight to your security page. It's a per-app route target, so nk-auth documents the convention rather than shipping it — in next.config:

async redirects() {
	return [
		{
			source: "/.well-known/change-password",
			destination: "/settings/security", // your Change/Set password page
			permanent: false,
		},
	];
}

Migrating bcrypt passwords to scrypt

bcryptPassword is legacy support only (see its @deprecated note). It exists so sites whose account.password hashes are bcrypt keep verifying — that is all nk-auth ships for the bcrypt case.

The path to move a site fully onto scrypt is a dual-format verifier, wired in the site (nk-auth does not ship it). Better Auth's default hasher is scrypt (<salt-hex>:<key-hex>) and bcrypt hashes are trivially distinguishable (they start with $2a$/$2b$/$2y$), so override only emailAndPassword.password.verify to branch on hash.startsWith("$2") → bcrypt compare, else Better Auth's scrypt verify. Old hashes keep working; every new signup, password change, and reset writes scrypt. Better Auth has no rehash-on-login and no "must reset" gate, so bcrypt hashes only upgrade when the user resets — or via a sign-in wrapper that persists a re-hash with internalAdapter.updatePassword.