@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.
Maintainers
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/loginwithfetch.
Install
npm install @aizvi/auth express
pnpm add @aizvi/auth express
yarn add @aizvi/auth express
bun add @aizvi/auth expressexpress 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 nodemailerimport 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 itsdata. Your app stores these itself (for example in secure storage) and sends the access token back asAuthorization: Bearer <accessToken>on future requests. The access token is short lived (15 minutes by default). When it expires, call/auth/refreshwith 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.txtOr 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 innode: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)
