auth-engine
v1.0.0
Published
A framework-agnostic, security-first authentication engine with JWT + database-backed refresh tokens.
Downloads
102
Maintainers
Readme
auth-engine
A framework-agnostic, security-first authentication engine with JWT access tokens and database-backed refresh token rotation.
Built for SaaS developers who need production-grade auth without vendor lock-in.
This library ships functions and interfaces — not a database. You implement two small interfaces using your own ORM (Prisma, Drizzle, raw SQL, etc.). The engine handles all the auth logic. Your database connection string lives entirely in your own code.
Why Auth Engine?
Most auth libraries are either too coupled to a framework or too simple for production. Auth Engine gives you a modular core that handles the hard parts — token rotation, reuse detection, session revocation — while staying completely decoupled from your framework and database.
What you get:
- Hybrid sessions — Short-lived JWT access tokens (stateless) + database-backed refresh tokens (revocable)
- Automatic token rotation — Refresh tokens rotate on every use
- Reuse detection — If a stolen token is replayed, all sessions are revoked instantly
- Framework adapters — Next.js App Router adapter included, bring your own for others
- Database agnostic — Implement two interfaces, use any ORM or database
- Zero vendor lock-in — Your auth, your infrastructure
Quick Start
Install
npm install auth-engine1. Implement the repository interfaces
Auth Engine needs two things from your database — a way to find users and a way to manage refresh tokens. Implement these interfaces with your ORM of choice (Prisma, Drizzle, Knex, raw SQL, etc.):
import type { UserRepository, RefreshTokenRepository } from "auth-engine";
// Example with Prisma — your DATABASE_URL lives in your own Prisma client, not here
import { prisma } from "@/lib/prisma"; // your DB client — auth-engine never sees your connection string
const userRepository: UserRepository = {
findByEmail: (email) => prisma.user.findUnique({ where: { email } }),
findById: (id) => prisma.user.findUnique({ where: { id } }),
create: (data) => prisma.user.create({ data }),
updatePassword: (userId, passwordHash) =>
prisma.user.update({ where: { id: userId }, data: { passwordHash } }).then(() => void 0),
};
const refreshTokenRepository: RefreshTokenRepository = {
create: (data) => prisma.refreshToken.create({ data }),
findByHash: (tokenHash) => prisma.refreshToken.findFirst({ where: { tokenHash } }),
revoke: (id) =>
prisma.refreshToken.update({
where: { id },
data: { revoked: true, revokedAt: new Date() },
}).then(() => void 0),
revokeAllByUser: (userId) =>
prisma.refreshToken.updateMany({
where: { userId, revoked: false },
data: { revoked: true, revokedAt: new Date() },
}).then(() => void 0),
deleteExpired: async (before) => {
const result = await prisma.refreshToken.deleteMany({ where: { expiresAt: { lt: before } } });
return result.count;
},
};2. Add environment variables
# Your database — used by YOUR ORM client, never by auth-engine
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# JWT signing secret — passed to createNextAuth config below
JWT_SECRET=your-random-secret-at-least-32-characters-longauth-engine only ever receives the JWT_SECRET. Your DATABASE_URL goes directly into your Prisma/Drizzle client — the library never touches it.
3. Set up with Next.js
// lib/auth.ts
import { createNextAuth } from "auth-engine/adapters/next";
export const { handlers, withAuth, engine } = createNextAuth({
config: {
accessToken: {
secret: process.env.JWT_SECRET!, // min 32 chars
expiresIn: "10m",
},
refreshToken: {
expiresInMs: 7 * 24 * 60 * 60 * 1000, // 7 days
},
},
userRepository,
refreshTokenRepository,
});4. Create API routes
// app/api/auth/login/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.login;// app/api/auth/register/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.register;// app/api/auth/refresh/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.refresh;// app/api/auth/logout/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.logout;5. Protect routes
// app/api/me/route.ts
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth";
export const GET = withAuth(async (req, { user }) => {
return NextResponse.json({ user: { id: user.id, email: user.email } });
});Architecture
┌─────────────────────────────────────┐
│ Your Application (Next.js, etc.) │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Adapter Layer │
│ HTTP handling, cookies, responses │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Auth Core Engine │
│ Framework-agnostic business logic │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Repository Contracts │
│ Interfaces only — you implement │
└─────────────────────────────────────┘Core rule: Lower layers never depend on upper layers. The core has zero framework imports.
Database Schema
You own the database. Auth Engine only defines the shape of the data it needs — you create the tables however you like.
Your users table must have at minimum: id, email, and passwordHash columns.
Prisma
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
refreshTokens RefreshToken[]
createdAt DateTime @default(now())
}
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
parentId String?
revoked Boolean @default(false)
revokedAt DateTime?
userAgent String?
ipHash String?
expiresAt DateTime
createdAt DateTime @default(now())
}SQL (raw)
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL,
parent_id UUID REFERENCES refresh_tokens(id),
revoked BOOLEAN DEFAULT FALSE,
revoked_at TIMESTAMP,
user_agent TEXT,
ip_hash TEXT,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);Security Model
| Threat | How Auth Engine handles it |
|---|---|
| Stolen access token | 10-minute expiry limits the damage window |
| Stolen refresh token | Tokens are hashed (SHA-256) before storage. Rotation on every use means the stolen token becomes invalid after one refresh |
| Token replay attack | Reuse detection: if a revoked token is presented, ALL of the user's sessions are revoked immediately |
| XSS | Refresh tokens stored in httpOnly cookies — invisible to JavaScript |
| CSRF | SameSite=strict on refresh cookie, SameSite=lax on access cookie |
Configuration Reference
interface AuthEngineConfig {
accessToken: {
secret: string; // JWT signing secret (min 32 chars)
expiresIn?: string; // Default: "10m"
algorithm?: string; // Default: "HS256"
issuer?: string; // Optional JWT issuer claim
};
refreshToken: {
expiresInMs?: number; // Default: 604800000 (7 days)
};
passwordHashing?: {
saltRounds?: number; // Default: 12
};
}Using the Core Directly
If you're not using Next.js or want to build your own adapter:
import { AuthEngine } from "auth-engine";
const engine = new AuthEngine({
config: { /* ... */ },
userRepository: myUserRepo,
refreshTokenRepository: myTokenRepo,
});
// Use directly
const tokens = await engine.login("[email protected]", "password");
const user = await engine.verifyAccessToken(tokens.accessToken);
await engine.refresh(tokens.refreshToken);
await engine.logout(tokens.refreshToken);
await engine.revokeAllSessions(userId);Error Handling
All errors are instances of AuthError with a consistent shape:
import { AuthError } from "auth-engine";
try {
await engine.login(email, password);
} catch (error) {
if (error instanceof AuthError) {
console.log(error.code); // "INVALID_CREDENTIALS"
console.log(error.status); // 401
console.log(error.toJSON());
// {
// success: false,
// error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password.", status: 401 }
// }
}
}Error codes: INVALID_CREDENTIALS, USER_NOT_FOUND, USER_ALREADY_EXISTS, ACCESS_TOKEN_EXPIRED, INVALID_ACCESS_TOKEN, INVALID_REFRESH_TOKEN, REFRESH_TOKEN_REUSED, SESSION_REVOKED
Important Notes
- Single-device in v1: Login revokes all existing sessions. If a user logs in on a new device, their old sessions end. Multi-device support is coming in v2.
- Rate limiting: Auth Engine does not include rate limiting. You should add it at the API layer for login and refresh endpoints.
- HTTPS: Auth Engine sets
secure: trueon cookies in production. Make sure your deployment is behind HTTPS. - JWT secret: Use at least 32 random characters. Never commit it to source control.
Roadmap
v2 — Multi-device sessions, role-based route protection, audit event logging, device management
v3 — Redis session strategy, OAuth providers, risk detection, CSRF module, rate limiting
Contributing
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
