guardo
A complete authentication engine for Node.js and Next.js. OTP login, JWT tokens, multi-device sessions, and middleware - wired together so you don't have to.
npm install guardo
Installation #
Install guardo from npm. Nodemailer is bundled. Install ioredis separately if you need Redis in production.
# Core package npm install guardo # Optional: Redis adapter for production npm install ioredis
pnpm add guardo
pnpm add ioredis # optional, for production
yarn add guardo
yarn add ioredis # optional, for production
express, next, and ioredis are all optional. Install only what you use.
Quick Start #
A full OTP login flow in three steps. No config needed to try it - OTPs print to the console by default.
import { createAuth } from "guardo"; const auth = createAuth({ jwt: { secret: process.env.JWT_SECRET! }, }); // ─── Step 1: Send OTP ───────────────────────────────────── await auth.otp.send({ identifier: "user@example.com" }); // → OTP delivered to inbox (or console in dev) // ─── Step 2: Verify OTP + login ─────────────────────────── const { user, accessToken, refreshToken, sessionId } = await auth.auth.loginWithOtp({ identifier: "user@example.com", otp: "123456", meta: { device: "chrome-mac", ip: req.ip }, }); // ─── Step 3: Protect routes ────────────────────────────── app.get("/me", auth.middleware.express(), (req, res) => { res.json(req.user); // req.user is automatically populated });
ConsoleNotifier explicitly or let it fall back to Ethereal test emails.
Configuration #
All options you can pass to createAuth(). Only jwt.secret is required.
import { createAuth, RedisStore } from "guardo"; const auth = createAuth({ // ─── Required ───────────────────────────────────────── jwt: { secret: "at-least-16-chars", accessTokenTTL: "15m", // default refreshTokenTTL: "7d", // default extraClaims: { iss: "my-app" }, // embedded in every token }, // ─── OTP ────────────────────────────────────────────── otp: { length: 6, // default expiry: 300, // seconds - default 5 minutes }, // ─── Storage ────────────────────────────────────────── store: new RedisStore(redisClient), // default: MemoryStore // ─── Notifications ──────────────────────────────────── notifier: myEmailNotifier, // default: ConsoleNotifier // ─── Rate limits ────────────────────────────────────── rateLimit: { otpSend: { max: 5, windowSeconds: 60 }, // default otpVerify: { max: 10, windowSeconds: 60 }, // default }, // ─── User resolution ────────────────────────────────── // Called after OTP verification and on every auth'd request. // Attach your DB call here so req.user is always full. resolveUser: async (identifier) => { return db.users.findByEmail(identifier); }, });
Config Reference
| Option | Type | Default | Description |
|---|---|---|---|
| jwt.secret | string | required | Secret key for signing JWTs. Min 16 chars. |
| jwt.accessTokenTTL | string | "15m" | Access token lifetime. e.g. "15m", "1h". |
| jwt.refreshTokenTTL | string | "7d" | Refresh token lifetime. e.g. "7d", "30d". |
| jwt.extraClaims | object | {} | Additional fields embedded in every token. |
| otp.length | number | 6 | Length of generated OTP codes. |
| otp.expiry | number | 300 | OTP TTL in seconds. |
| store | StorageAdapter | MemoryStore | Where OTPs and sessions are stored. |
| notifier | Notifier | NodemailerNotifier | How OTPs are delivered. |
| rateLimit.otpSend | RateLimitRule | 5/60s | Max OTP send requests per window. |
| rateLimit.otpVerify | RateLimitRule | 10/60s | Max OTP verify requests per window. |
| resolveUser | function | undefined | Return a full User from an identifier. |
OTP Module #
Accessed via auth.otp. Handles generating, delivering, and verifying one-time passwords.
const result = await auth.otp.send({ identifier: "user@example.com", channel: "email", // or "sms" - default "email" }); // → { expiresInSeconds: 300 }
const result = await auth.otp.verify({ identifier: "user@example.com", otp: "123456", }); // Success: // { success: true, verified: true } // Failure: // { success: false, verified: false, error: "Invalid OTP. 4 attempt(s) remaining." } // { success: false, verified: false, error: "OTP expired or not found." }
const pending = await auth.otp.exists("user@example.com"); // → true or false
Auth Module #
Accessed via auth.auth. High-level flows that orchestrate OTP, JWT, and Session modules together.
AuthError if OTP is invalid or user not found.const result = await auth.auth.loginWithOtp({ identifier: "user@example.com", otp: "123456", meta: { // optional session metadata device: "iPhone 15", ip: "1.2.3.4", userAgent: req.headers["user-agent"], }, }); // result: { // user: { id: "123", email: "user@example.com" }, // accessToken: "eyJ...", // refreshToken: "eyJ...", // sessionId: "sess_abc123", // }
const { accessToken, refreshToken, sessionId } = await auth.auth.refreshTokens(oldRefreshToken);
await auth.auth.logout("sess_abc123"); // single device const count = await auth.auth.logoutAll("user-123"); // → 3 sessions revoked
JWT Module #
Accessed via auth.jwt. Low-level token operations. Typically you won't need this - auth.auth handles token issuance automatically.
// Issue tokens manually const accessToken = auth.jwt.issueAccessToken(user, sessionId); const refreshToken = auth.jwt.issueRefreshToken(user, sessionId); const { accessToken, refreshToken } = auth.jwt.issueTokenPair(user, sessionId); // Verify (throws on invalid or expired) const payload = auth.jwt.verifyAccessToken(token); const payload = auth.jwt.verifyRefreshToken(token); // Decode without verifying (read sub from expired token) const payload = auth.jwt.decode(token); // returns null if unparseable
Session Module #
Accessed via auth.session. Multi-device session management backed by the storage adapter.
// Create a session (sessionId is auto-generated) const session = await auth.session.create(userId, { device: "Safari on iPad", ip: "1.2.3.4", userAgent: "Mozilla/5.0...", }); // Get one session by ID const session = await auth.session.get("sess_abc123"); // List all sessions for a user (newest first) const sessions = await auth.session.list(userId); // Touch: update lastActiveAt (call on each auth'd request) await auth.session.touch("sess_abc123"); // Revoke one session await auth.session.revoke("sess_abc123"); // Revoke all sessions for a user → returns count const n = await auth.session.revokeAll(userId);
Session Object Shape
| Field | Type | Description |
|---|---|---|
| sessionId | string | Unique ID, format: sess_{hex} |
| userId | string | The user this session belongs to |
| device | string? | Device label from session meta |
| ip | string? | IP address from session meta |
| userAgent | string? | User-Agent string |
| createdAt | string | ISO timestamp of creation |
| lastActiveAt | string | ISO timestamp of last touch |
Express Middleware #
auth.middleware.express()
Validates the Authorization: Bearer <token> header, populates req.user and req.session, and calls session.touch() on each request.
app.get("/profile", auth.middleware.express(), (req, res) => { res.json(req.user); });
auth.middleware.role(roles)
Role-based access control. Must be placed after express(). Returns 403 if the user's role isn't in the allowed list.
app.delete( "/admin/users/:id", auth.middleware.express(), auth.middleware.role(["admin", "superadmin"]), handler, );
role() must always come after express() - it relies on req.user being set first.
Next.js Middleware #
Edge Middleware - auth.middleware.nextjs()
Protect entire route groups in the Next.js Edge runtime. Export from your project-root middleware.ts.
import { createAuth } from "guardo"; const auth = createAuth({ jwt: { secret: process.env.JWT_SECRET! } }); export const middleware = auth.middleware.nextjs(); export const config = { matcher: ["/api/:path*", "/dashboard/:path*"], };
App Router Route Wrapper - auth.middleware.nextjsRoute()
Wrap individual API route handlers in the App Router. The handler receives the verified user as the second argument.
export const GET = auth.middleware.nextjsRoute(async (req, user) => { return Response.json({ user }); });
Storage Adapters #
guardo stores OTPs, sessions, and rate limit windows in a StorageAdapter. Swap between implementations without changing your auth code.
In-Memory Store - dev & test only
The default. Data is lost on process restart - perfect for development and tests.
import { MemoryStore } from "guardo"; const store = new MemoryStore(); store.clear(); // useful in tests - wipe everything
Redis Store - production recommended
Backed by ioredis. Works across multiple server instances. Install ioredis separately.
import { RedisStore, createAuth } from "guardo"; import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL); const store = new RedisStore(redis); const auth = createAuth({ jwt: { secret: "..." }, store });
Custom Store
Implement StorageAdapter to use any database (MongoDB, DynamoDB, PostgreSQL, etc.).
import type { StorageAdapter } from "guardo"; class MongoStore implements StorageAdapter { async set(key: string, value: string, ttlSeconds?: number) { /* ... */ } async get(key: string): Promise<string | null> { /* ... */ } async delete(key: string) { /* ... */ } async keys(prefix: string): Promise<string[]> { /* ... */ } }
Notifiers #
Notifiers deliver OTP codes to users. Swap between Console (dev), Nodemailer (email), or build your own.
Console Notifier - dev only
Logs OTPs to stdout. Zero configuration. Great for local development.
import { ConsoleNotifier, createAuth } from "guardo"; const auth = createAuth({ jwt: { secret: "..." }, notifier: new ConsoleNotifier(), }); // Console: [guardo] OTP for user@example.com via email: 483920 (expires in 300s)
Nodemailer (Email)
Two modes: Ethereal (automatic test inbox, no config) or real SMTP (Gmail, Resend, SendGrid, etc.).
import { NodemailerNotifier, createAuth } from "guardo"; // No smtp config → auto-creates Ethereal test account const auth = createAuth({ jwt: { secret: "..." }, notifier: new NodemailerNotifier(), }); // Outputs preview URL to console after sending
const auth = createAuth({ jwt: { secret: "..." }, notifier: new NodemailerNotifier({ smtp: { host: "smtp.gmail.com", // or sendgrid, resend, etc. port: 587, secure: false, // STARTTLS user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, from: "noreply@myapp.com", subject: "Your login code", }), });
const notifier = new NodemailerNotifier({ smtp: { host: "...", user: "...", pass: "..." }, buildHtml: (code, expiresInSeconds) => ` <h2>Sign in to My App</h2> <p>Your code: <strong>${code}</strong></p> <p>Valid for ${Math.round(expiresInSeconds! / 60)} minutes.</p> `, buildText: (code, expiresInSeconds) => `Sign in code: ${code}\nValid for ${Math.round(expiresInSeconds! / 60)} minutes.`, });
Custom Notifier
Implement the Notifier interface with a single sendOTP method.
import type { Notifier, NotifyPayload } from "guardo"; class TwilioNotifier implements Notifier { async sendOTP({ to, code, expiresInSeconds }: NotifyPayload) { await twilio.messages.create({ to, from: process.env.TWILIO_NUMBER, body: `Your code is ${code}. Expires in ${expiresInSeconds}s.`, }); } } // Functional shorthand import { createNotifier } from "guardo"; const notifier = createNotifier(async ({ to, code }) => { console.log(`Send ${code} to ${to}`); });
Multi-Channel Notifier
Route different channels (email/SMS) to different notifier implementations.
import { MultiChannelNotifier } from "guardo"; const auth = createAuth({ jwt: { secret: "..." }, notifier: new MultiChannelNotifier({ email: new SendGridNotifier(), sms: new TwilioNotifier(), }), });
Error Handling #
All errors extend Error and have a .name property for easy instanceof checks.
| Class | Thrown by | Extra property |
|---|---|---|
| AuthError | auth.auth.* - bad OTP, revoked session, user not found | - |
| RateLimitError | auth.otp.send() - too many requests | .retryAfterSeconds |
| TokenTypeError | auth.jwt.verify*() - wrong token type | - |
| JsonWebTokenError | auth.jwt.verify*() - invalid signature | - |
| TokenExpiredError | auth.jwt.verify*() - expired token | - |
import { AuthError, RateLimitError } from "guardo"; try { await auth.auth.loginWithOtp({ identifier, otp }); } catch (err) { if (err instanceof RateLimitError) { res.status(429).json({ error: `Retry in ${err.retryAfterSeconds}s` }); } else if (err instanceof AuthError) { res.status(401).json({ error: err.message }); } }
TypeScript Types #
Key types exported from guardo. All types are fully documented with JSDoc in-editor.
| Type | Description |
|---|---|
| User | { id, email?, phone?, role?, ...extra } |
| Session | Full session object with device, ip, timestamps |
| TokenPair | { accessToken, refreshToken } |
| TokenPayload | Decoded JWT payload with sub, type, sessionId |
| LoginResult | { user, accessToken, refreshToken, sessionId } |
| StorageAdapter | Interface for custom storage backends |
| Notifier | Interface with single sendOTP(payload) method |
| AuthConfig | Top-level config passed to createAuth() |
Security Notes #
- ✓ Hashed OTP storage - plaintext OTPs are never persisted. SHA-256 hash is stored instead.
-
✓
Timing-safe comparison - OTP verification uses
crypto.timingSafeEqualto prevent timing attacks. - ✓ One-time use - each OTP is consumed on successful verification and can't be reused.
- ✓ Attempt limiting - after 5 failed attempts, the OTP is automatically invalidated.
- ✓ Refresh token rotation - every refresh creates a new session, invalidating the old one.
- ✓ TTL-bound sessions - sessions expire automatically in line with the refresh token lifetime.
Changelog #
All notable changes to guardo. Follows Keep a Changelog format.