🔐 Production-Ready · TypeScript · v1.0.1

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
📱
OTP Login
Generate, send & verify time-limited codes via email or SMS.
🎟️
JWT Tokens
Access + refresh token pairs with configurable TTLs.
💻
Sessions
Multi-device sessions with per-device revocation.
🛡️
Middleware
Express guards and Next.js Edge-compatible wrappers.
🗄️
Pluggable Storage
In-memory (dev) or Redis (prod) - or bring your own.
🚦
Rate Limiting
Per-identifier sliding-window limits built in.

Installation #

Install guardo from npm. Nodemailer is bundled. Install ioredis separately if you need Redis in production.

bash
# Core package
npm install guardo

# Optional: Redis adapter for production
npm install ioredis
bash
pnpm add guardo
pnpm add ioredis # optional, for production
bash
yarn add guardo
yarn add ioredis # optional, for production
ℹ️ Peer dependencies: 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.

auth.ts
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
});
💡 In development, OTPs are printed to the console automatically - no email setup needed. Use ConsoleNotifier explicitly or let it fall back to Ethereal test emails.

Configuration #

All options you can pass to createAuth(). Only jwt.secret is required.

auth.ts
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

OptionTypeDefaultDescription
jwt.secretstringrequiredSecret key for signing JWTs. Min 16 chars.
jwt.accessTokenTTLstring"15m"Access token lifetime. e.g. "15m", "1h".
jwt.refreshTokenTTLstring"7d"Refresh token lifetime. e.g. "7d", "30d".
jwt.extraClaimsobject{}Additional fields embedded in every token.
otp.lengthnumber6Length of generated OTP codes.
otp.expirynumber300OTP TTL in seconds.
storeStorageAdapterMemoryStoreWhere OTPs and sessions are stored.
notifierNotifierNodemailerNotifierHow OTPs are delivered.
rateLimit.otpSendRateLimitRule5/60sMax OTP send requests per window.
rateLimit.otpVerifyRateLimitRule10/60sMax OTP verify requests per window.
resolveUserfunctionundefinedReturn a full User from an identifier.

OTP Module #

Accessed via auth.otp. Handles generating, delivering, and verifying one-time passwords.

async otp.send (opts: SendOtpOptions) → Promise<{ expiresInSeconds: number }>
Generates a fresh OTP, stores it hashed, and delivers it via the configured notifier. Calling again before expiry replaces the previous code and resets the attempt counter.
typescript
const result = await auth.otp.send({
  identifier: "user@example.com",
  channel: "email", // or "sms" - default "email"
});
// → { expiresInSeconds: 300 }
async otp.verify (opts: VerifyOtpOptions) → Promise<VerifyOtpResult>
Verifies the user-entered OTP. Consumes it on success (one-time use). After 5 failed attempts, the OTP is automatically invalidated.
typescript
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." }
async otp.exists (identifier: string) → Promise<boolean>
Check whether a valid (unexpired) OTP exists for an identifier. Useful for showing "resend" UI.
typescript
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.

async auth.loginWithOtp (opts: LoginWithOtpOptions) → Promise<LoginResult>
One-shot login: verifies OTP → creates a session → issues access + refresh tokens. Throws AuthError if OTP is invalid or user not found.
typescript
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",
// }
async auth.refreshTokens (refreshToken: string) → Promise<TokenPair & { sessionId }>
Exchange a valid refresh token for a fresh token pair. Uses token rotation - old session is revoked and a new one is created.
typescript
const { accessToken, refreshToken, sessionId } =
  await auth.auth.refreshTokens(oldRefreshToken);
async auth.logout (sessionId: string) → Promise<void>
Revoke a specific session (single-device logout).
async auth.logoutAll (userId: string) → Promise<number>
Revoke ALL sessions for a user (logout everywhere). Returns the number of sessions revoked.
typescript
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.

typescript
// 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.

typescript
// 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

FieldTypeDescription
sessionIdstringUnique ID, format: sess_{hex}
userIdstringThe user this session belongs to
devicestring?Device label from session meta
ipstring?IP address from session meta
userAgentstring?User-Agent string
createdAtstringISO timestamp of creation
lastActiveAtstringISO 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.

routes.ts
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.

routes.ts
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.

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.

app/api/me/route.ts
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.

typescript
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.

typescript
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.).

typescript
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.

typescript
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.).

typescript
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
typescript
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",
  }),
});
typescript
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.

typescript
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.

typescript
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.

ClassThrown byExtra property
AuthErrorauth.auth.* - bad OTP, revoked session, user not found-
RateLimitErrorauth.otp.send() - too many requests.retryAfterSeconds
TokenTypeErrorauth.jwt.verify*() - wrong token type-
JsonWebTokenErrorauth.jwt.verify*() - invalid signature-
TokenExpiredErrorauth.jwt.verify*() - expired token-
typescript
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.

TypeDescription
User{ id, email?, phone?, role?, ...extra }
SessionFull session object with device, ip, timestamps
TokenPair{ accessToken, refreshToken }
TokenPayloadDecoded JWT payload with sub, type, sessionId
LoginResult{ user, accessToken, refreshToken, sessionId }
StorageAdapterInterface for custom storage backends
NotifierInterface with single sendOTP(payload) method
AuthConfigTop-level config passed to createAuth()

Security Notes #

🔒 guardo is designed with security-first defaults. Here's what happens under the hood.
  • Hashed OTP storage - plaintext OTPs are never persisted. SHA-256 hash is stored instead.
  • Timing-safe comparison - OTP verification uses crypto.timingSafeEqual to 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.

🗺️ Roadmap: OAuth providers (Google, GitHub), device fingerprinting, risk scoring, and analytics hooks are planned. Watch the GitHub repo for updates.