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

@aizvi/auth

v1.1.2

Published

Framework-agnostic, database-agnostic auth core: signup, login, email verification, password reset, and JWT sessions for web + mobile clients.

Readme

@aizvi/auth

A complete, ready-to-use auth system for a Node.js backend: signup, email verification, login, password reset, and sessions for both web and mobile apps, without locking you into a specific database or frontend framework.

You get an Express router that handles all the HTTP endpoints. You bring two small things: something that saves and reads users (a database adapter) and something that sends emails (a mailer). Everything else, including password hashing, JWTs, cookies, refresh token rotation, and verification codes, is handled for you.

Who this is for

  • You're building a backend in Node.js (Express) and don't want to write signup, login, and password reset from scratch again.
  • You want the same auth logic to work for both your website and your mobile app. This package handles both out of the box.
  • You don't want to be locked into one database. Postgres, MySQL, SQLite, MongoDB, anything works, as long as you (or someone else) has written a small adapter for it. A ready-made SQLite adapter is available as @aizvi/auth-sqlite.
  • Your frontend can be anything: React, Vue, Angular, Next.js, or a mobile app, because it never talks to this package directly. It just calls plain HTTP endpoints like POST /auth/login with fetch.

Install

npm install @aizvi/auth express
pnpm add @aizvi/auth express
yarn add @aizvi/auth express
bun add @aizvi/auth express

express is a peer dependency. You need it in your project already, or you can install it alongside.

Getting started

This example uses @aizvi/auth-sqlite for storage and Nodemailer for email, but you can swap either one out. See Bring your own database and Bring your own email provider below.

npm install @aizvi/auth @aizvi/auth-sqlite express nodemailer
import express from 'express';
import nodemailer from 'nodemailer';
import { createAuthRouter } from '@aizvi/auth';
import { sqliteAdapter } from '@aizvi/auth-sqlite';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

const app = express();
app.use(express.json());

app.use(
  '/auth',
  createAuthRouter({
    // Where users and sessions are stored. Swap this for your own database.
    adapter: sqliteAdapter({ file: './data.sqlite' }),

    // How verification and password reset codes get emailed. Swap this for
    // your own provider (Resend, SendGrid, SES, and so on).
    mailer: {
      async sendVerificationEmail(email, code) {
        await transporter.sendMail({
          to: email,
          subject: 'Verify your account',
          text: `Your verification code is ${code}`,
        });
      },
      async sendPasswordResetEmail(email, code) {
        await transporter.sendMail({
          to: email,
          subject: 'Reset your password',
          text: `Your password reset code is ${code}`,
        });
      },
    },

    // A long, random secret used to sign sessions. Keep this in an
    // environment variable, never commit it.
    jwtSecret: process.env.JWT_SECRET!,
  })
);

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

That's it. Your app now has working /auth/signup, /auth/login, /auth/verify-email, /auth/resend-verification, /auth/forgot-password, /auth/reset-password, /auth/me, /auth/refresh, and /auth/logout endpoints.

How web and mobile clients differ

The same endpoints serve both kinds of client. The router decides how to respond based on one request header:

  • Web (a browser, no special header): the session is stored in an httpOnly cookie, so client-side JavaScript never touches the token directly. This is the safer default for browsers.
  • Mobile (send the header X-Client-Type: mobile): instead of a cookie, the response body includes { accessToken, refreshToken } in its data. Your app stores these itself (for example in secure storage) and sends the access token back as Authorization: Bearer <accessToken> on future requests. The access token is short lived (15 minutes by default). When it expires, call /auth/refresh with the refresh token to get a new pair. Each refresh token can only be used once. Using it issues a brand new pair and invalidates the old one, so a stolen, already used token is worthless.

Example: web login from a browser.

const res = await fetch('/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include', // send/receive the session cookie
  body: JSON.stringify({ email, password }),
});
const { data } = await res.json(); // { user: { id, email } }

Example: mobile login from a mobile app.

const res = await fetch('https://your-api.com/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Client-Type': 'mobile' },
  body: JSON.stringify({ email, password }),
});
const { data } = await res.json(); // { user, accessToken, refreshToken }
// Save data.accessToken and data.refreshToken securely on the device.

API routes

All routes are mounted under whatever path you choose (/auth in the examples above).

| Method | Path | What it does | | ------ | ---------------------- | --------------------------------------------------------------------------------- | | POST | /signup | Creates an account and emails a verification code. | | POST | /verify-email | Confirms a verification code, marks the account verified, and signs the user in. | | POST | /resend-verification | Sends a new verification code (rate limited by verificationCodeCooldownSeconds). | | POST | /login | Signs in with email and password. | | POST | /refresh | Exchanges a mobile refresh token for a new access and refresh token pair. | | POST | /forgot-password | Emails a password reset code, if the account exists. | | POST | /reset-password | Sets a new password using a reset code. | | GET | /me | Returns the signed in user. Requires a valid session. | | POST | /logout | Signs out and invalidates the refresh token, if one was provided. |

Every error response includes a message describing what went wrong. For example, "Invalid email or password" or "Please wait 42 seconds before requesting another verification code".

Trying the API directly

These examples use curl, so they work the same from any client, any language, or just your terminal, while you're building or debugging. They assume the router is mounted at /auth on http://localhost:3000 and use the default response shape ({ success, message, data }). If you've set formatSuccessResponse/formatErrorResponse, your actual response bodies will look different, but the requests themselves are identical.

Sign up.

curl -X POST http://localhost:3000/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "correct-horse-battery"}'
{ "success": true, "message": "A verification code has been sent to your email." }

Verify the email (web). Add -c cookies.txt to save the session cookie for later requests.

curl -X POST http://localhost:3000/auth/verify-email \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "code": "482913"}'
{
  "success": true,
  "message": "Email verified successfully",
  "data": { "user": { "id": "b3f1...", "email": "[email protected]" } }
}

Verify the email (mobile). Send X-Client-Type: mobile instead, and you get a token pair back instead of a cookie.

curl -X POST http://localhost:3000/auth/verify-email \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"email": "[email protected]", "code": "482913"}'
{
  "success": true,
  "message": "Email verified successfully",
  "data": {
    "user": { "id": "b3f1...", "email": "[email protected]" },
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "6f9c2b8a..."
  }
}

Resend the verification code.

curl -X POST http://localhost:3000/auth/resend-verification \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'
{ "success": true, "message": "Verification code resent" }

Log in (web). Reuses the cookie jar from the verify-email step, or starts a fresh one.

curl -X POST http://localhost:3000/auth/login \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "correct-horse-battery"}'
{
  "success": true,
  "message": "Signed in successfully",
  "data": { "user": { "id": "b3f1...", "email": "[email protected]" } }
}

Log in (mobile).

curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"email": "[email protected]", "password": "correct-horse-battery"}'
{
  "success": true,
  "message": "Signed in successfully",
  "data": {
    "user": { "id": "b3f1...", "email": "[email protected]" },
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "6f9c2b8a..."
  }
}

A wrong password or unknown email responds 401 with { "success": false, "message": "Invalid email or password" }. An unverified account responds 403 with { "success": false, "message": "Please verify your email before logging in", "code": "EMAIL_NOT_VERIFIED" }.

Get the current user. Using the cookie from a web login:

curl http://localhost:3000/auth/me -b cookies.txt

Or using a mobile access token:

curl http://localhost:3000/auth/me \
  -H "Authorization: Bearer eyJhbGciOi..."
{
  "success": true,
  "message": "OK",
  "data": { "user": { "id": "b3f1...", "email": "[email protected]" } }
}

With no cookie and no Authorization header, this responds 401 with { "success": false, "message": "Unauthorized" }.

Refresh a mobile session. Exchanges a refresh token for a brand new access and refresh token pair. The old refresh token stops working the moment this succeeds.

curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "6f9c2b8a..."}'
{
  "success": true,
  "message": "Session refreshed",
  "data": {
    "user": { "id": "b3f1...", "email": "[email protected]" },
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "a71fd400..."
  }
}

Request a password reset. Always responds the same way, whether or not the email exists, so it can't be used to check which emails have accounts.

curl -X POST http://localhost:3000/auth/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'
{ "success": true, "message": "If that email exists, a reset code has been sent." }

Reset the password.

curl -X POST http://localhost:3000/auth/reset-password \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "code": "738201", "password": "a-new-password"}'
{ "success": true, "message": "Password reset successfully" }

Log out. Include a refreshToken to also revoke a mobile session; it's optional. Web logout (with the cookie jar) clears the session cookie.

curl -X POST http://localhost:3000/auth/logout \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "a71fd400..."}'
{ "success": true, "message": "Signed out" }

Configuration reference

createAuthRouter(config) accepts:

| Option | Required | Default | What it controls | | ---------------------------------- | -------- | ------------------------------------ | ----------------- | | adapter | yes | (none) | Your database adapter. See Bring your own database. | | mailer | yes | (none) | Your email sender. See Bring your own email provider. | | jwtSecret | yes | (none) | Secret used to sign session and access tokens. | | cookieName | no | "auth_token" | Name of the web session cookie. | | cookieSecure | no | true in production | Whether the cookie requires HTTPS. | | cookieSameSite | no | "Lax" | Cookie SameSite attribute ("Lax", "Strict", or "None"). | | webTokenExpiresIn | no | "7d" | How long a web session lasts. | | accessTokenExpiresIn | no | "15m" | How long a mobile access token lasts before it needs refreshing. | | refreshTokenDays | no | 90 | How many days a mobile refresh token stays valid if unused. | | verificationCodeCooldownSeconds | no | 60 | Minimum time between resend requests for the same account. | | verificationCodeExpiryMinutes | no | 15 | How long a verification or reset code stays valid. | | verificationCodeLength | no | 6 | Digits in a generated code. Ignored if generateVerificationCode is set. | | generateVerificationCode | no | a random numeric code generator | Supply your own function (() => string) for full control over the code format. | | formatSuccessResponse | no | { success: true, message, data } | Reshape every successful JSON response. Receives (statusCode, message, data). | | formatErrorResponse | no | { success: false, message, code } | Reshape every error JSON response. Receives (statusCode, message, code). | | setAuthCookie / clearAuthCookie| no | a single httpOnly cookie | Override exactly how the web session cookie is written and cleared. | | mapMeUser | no | { id, email } | Add extra fields to what GET /me returns, for example isVerified or createdAt. | | onEmailVerified | no | (none) | A function called after a signup code is confirmed for the first time. Never blocks or fails the response. |

Customization examples

The options above exist so this package can match any existing API contract exactly. This is especially useful if you're adding this package to an app that already has its own response format, cookie scheme, or /me shape, and you don't want to change your frontend at all.

Match an existing API's response envelope.

createAuthRouter({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  formatSuccessResponse: (statusCode, message, data) => ({
    status: statusCode,
    ok: true,
    message,
    data,
  }),
  formatErrorResponse: (statusCode, message, code) => ({
    status: statusCode,
    ok: false,
    message,
    code,
  }),
});

Set a second, readable cookie alongside the real session cookie.

Some frontends want a cheap way to know "there might be a session" before calling /me, without being able to read the actual (httpOnly) token. You can set an extra cookie yourself in setAuthCookie and clearAuthCookie:

createAuthRouter({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  setAuthCookie: (res, token, cookieConfig) => {
    res.setHeader('Set-Cookie', [
      `${cookieConfig.name}=${token}; Path=/; HttpOnly; Max-Age=${Math.floor(cookieConfig.maxAgeMs / 1000)}`,
      `has_session=1; Path=/; Max-Age=${Math.floor(cookieConfig.maxAgeMs / 1000)}`,
    ]);
  },
  clearAuthCookie: (res, cookieConfig) => {
    res.setHeader('Set-Cookie', [
      `${cookieConfig.name}=; Path=/; HttpOnly; Max-Age=0`,
      'has_session=; Path=/; Max-Age=0',
    ]);
  },
});

Use a different verification code format.

createAuthRouter({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  // Just change the length:
  verificationCodeLength: 8,
  // Or take full control of the format:
  // generateVerificationCode: () => crypto.randomInt(1_000_000, 9_999_999).toString(),
});

Return extra fields from GET /me.

createAuthRouter({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  mapMeUser: (user) => ({
    id: user.id,
    email: user.email,
    isVerified: user.isVerified,
    memberSince: user.createdAt,
  }),
});

Send yourself a notification when someone verifies their account.

createAuthRouter({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  onEmailVerified: (user) => {
    // Fire and forget: this never blocks or fails the user's own request.
    notifyTeamOfNewSignup(user.email).catch((err) =>
      console.error('New signup notification failed', err)
    );
  },
});

Bring your own database

This package never talks to a database directly. You give it an adapter object that knows how to read and write users and sessions. Implement the AuthAdapter interface:

interface AuthAdapter {
  findUserByEmail(email: string): Promise<User | null>;
  findUserById(id: string): Promise<User | null>;
  createUser(data: CreateUserInput): Promise<User>;
  updateUser(id: string, patch: Partial<Omit<User, 'id'>>): Promise<void>;
  createRefreshSession(data: CreateRefreshSessionInput): Promise<void>;
  findRefreshSession(tokenHash: string): Promise<RefreshSession | null>;
  revokeRefreshSession(tokenHash: string): Promise<void>;
}

All the types referenced above (User, CreateUserInput, CreateRefreshSessionInput, RefreshSession) are exported from this package. See src/types.ts for the exact shapes.

Already available:

  • @aizvi/auth-sqlite: SQLite, including support for Node's built in node:sqlite, so you can point it at a database connection you already have.

Writing your own adapter for Postgres, MySQL, MongoDB, or anything else is usually a small, focused piece of code: a handful of queries mapped onto the interface above.

Bring your own email provider

Same idea. Implement EmailSender:

interface EmailSender {
  sendVerificationEmail(email: string, code: string): Promise<void>;
  sendPasswordResetEmail(email: string, code: string): Promise<void>;
}

Wire it up to whatever you already use to send email: Nodemailer, Resend, SendGrid, Amazon SES, Postmark, or anything else that can send a plain email.

Protecting your own routes

Everything you need to check "is this request authenticated?" is exported, so you can protect routes outside of the auth router itself:

import { authMiddleware } from '@aizvi/auth';

app.get(
  '/profile',
  authMiddleware(process.env.JWT_SECRET!, 'auth_token'), // (jwtSecret, cookieName)
  (req, res) => {
    res.json({ userId: (req as any).user.id });
  }
);

It checks the same cookie or Authorization: Bearer header the router itself uses, and responds 401 if the request isn't authenticated.

If you're customizing formatErrorResponse and want your own routes' 401 responses to use that exact same shape, build the check yourself from the lower level pieces this package also exports, getAuthCookie and verifyToken, the same way authMiddleware does internally:

import { getAuthCookie, verifyToken } from '@aizvi/auth';

function myAuthMiddleware(req, res, next) {
  const token =
    getAuthCookie(req, 'auth_token') ||
    (req.headers.authorization || '').replace(/^Bearer /, '');

  const decoded = token ? verifyToken(token, process.env.JWT_SECRET!) : null;
  if (!decoded) {
    return res.status(401).json({ status: 401, ok: false, message: 'Unauthorized' });
  }

  req.user = { id: decoded.id };
  next();
}

Errors

Anything this package rejects with is an instance of AuthError, which has .status (an HTTP status code), .message, and an optional .code (for example "EMAIL_NOT_VERIFIED" on a login attempt with an unverified account). If you're using createAuthService directly (see below) instead of the router, catch AuthError to handle these the same way the router does internally.

Using the core logic without Express

If you're not using Express, or want to expose this over a different transport (GraphQL, tRPC, a CLI, and so on), use createAuthService directly. It has no dependency on Express at all:

import { createAuthService } from '@aizvi/auth';

const auth = createAuthService({
  adapter,
  mailer,
  jwtSecret: process.env.JWT_SECRET!,
  webTokenExpiresIn: '7d',
  accessTokenExpiresIn: '15m',
  refreshTokenDays: 90,
  verificationCodeCooldownSeconds: 60,
  verificationCodeExpiryMinutes: 15,
});

await auth.signup({ email, password });
await auth.login({ email, password });
// ...and so on. These are the same operations the router's endpoints call internally.

TypeScript

Written in TypeScript. Type definitions are included, no @types package needed. Works from plain JavaScript too.

Code of Conduct

See CODE_OF_CONDUCT.md.

License

MIT (see license.txt)