Observed since 20 Oct, 21:53 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Minimal JWT authentication for Node.js and Express APIs with refresh-token rotation, HTTP-only cookies, and RBAC
Minimal JWT authentication for Node.js and Express APIs.
No database. No hosted auth service. Just access tokens, refresh-token rotation, HTTP-only cookies, and RBAC with middleware your team can understand.
[!NOTE] Any release with breaking changes will list them in this section before the rest of the README. Legacy
isRevoked+revokeRefreshTokenhooks remain supported for compatibility, but emit a warning and are not concurrency-safe. Use the atomicconsumeRefreshTokenhook for rotated refresh tokens, especially in multi-instance deployments. The newRefreshTokenStoreAPI is the recommended integration for shared rotation state.
Start here: 5-minute quick start · runnable examples · API reference
Documentation: zero-auth.netlify.app · guides · API reference
JWT auth with refresh-token rotation and reuse detection for teams that want to keep identity and storage in their own application.
ES256, RS256, EdDSA, PS256) or standard HMAC (HS256, HS384, HS512)./.well-known/jwks.json, Clerk, Auth0, Firebase, AWS Cognito) with automated caching, cooldown, and key rotation.auth.jwksHandler() or export JWKS with auth.getJwks().nbf validation.createAuth() and start securing routes immediately.auth.authorize(['admin', 'editor'])).auth.authorizePermissions(['users:read']).req.user typing via declaration merging with support for custom claims.AuthError class with typed error codes (AUTH_TOKEN_EXPIRED, AUTH_FORBIDDEN, etc.).zero-auth is an authentication layer, not a complete identity platform. It
does not provide a user database, password hashing, OAuth providers, email
verification, password reset, MFA, or user-management UI. Your application
owns those decisions and supplies the user identity used in token claims.
| Concern | Default approach |
|---|---|
| Access tokens | Short-lived JWTs, typically 15m |
| Refresh tokens | Longer-lived tokens, typically 7d |
| Rotation | Enable refreshOptions.rotate when replay detection matters |
| Revocation | Your Redis, database, or other application store |
| Browser storage | HTTP-only cookies when a cookie flow is appropriate |
zero-auth does not choose or manage your user database. Read the full
security checklist before deploying
to production.
npm install @0-auth/zero-auth
# or
yarn add @0-auth/zero-auth
# or
pnpm add @0-auth/zero-auth
Note: If using Express middleware, install
express(peer dependency):npm install express
Here is a complete, copy-pasteable Express application using zero-auth:
Set JWT_ACCESS_SECRET and JWT_REFRESH_SECRET to different random values of
at least 32 characters before starting the app.
import express from "express";
import { createAuth } from "@0-auth/zero-auth";
// 1. Initialize Auth instance
const auth = createAuth({
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
accessExpiresIn: "15m",
refreshExpiresIn: "7d",
});
const app = express();
app.use(express.json());
// 2. Login Route (generates and sends token pair or cookies)
app.post("/auth/login", async (req, res, next) => {
try {
const { email, password } = req.body;
// ... validate credentials against your database ...
const userPayload = { id: "user-123", email, role: "admin" };
// Option A: Set HTTP-Only cookies + return tokens
const tokens = await auth.sendAuthTokens(res, userPayload);
res.json({ message: "Logged in successfully", tokens });
// Option B (Bearer only): const tokens = await auth.generateTokenPair(userPayload); res.json(tokens);
} catch (err) {
next(err);
}
});
// 3. Protected Route (requires valid access token)
app.get("/api/profile", auth.protect(), (req, res) => {
// req.user is automatically typed as AuthUser!
res.json({ user: req.user });
});
// 4. Role-Restricted Route (Admin only)
app.get("/api/admin/dashboard", auth.protect(), auth.authorize(["admin"]), (req, res) => {
res.json({ message: `Welcome Admin ${req.user?.id}` });
});
// 5. Refresh Token Route
app.post("/auth/refresh", auth.refreshHandler());
// 6. Logout Route (clears HTTP-Only cookies)
app.post("/auth/logout", (req, res) => {
auth.clearAuth(res);
res.json({ message: "Logged out successfully" });
});
// 7. Error handling middleware (must be registered after routes)
app.use(auth.errorHandler);
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
Three complete, runnable example projects are included in the
examples/ directory:
| Example | Description | Features |
|---|---|---|
express-rest-api | Stateless Bearer token API for mobile / CLI / SPA clients | generateTokenPair, protect, authorize, optional, refreshHandler, decodeToken |
express-cookies-redis | Cookie-based auth with Redis-backed refresh token rotation | sendAuthTokens, clearAuth, CSRF, rotation hooks, family revocation, onRefreshReuse |
express-jwks-microservices | Central Auth (RS256) + Downstream Microservices via Remote JWKS | jwksUri, jwksHandler, getJwks, Verifier-Only mode, dynamic key rotation |
Each example includes a README with setup instructions and cURL commands for every endpoint.
For the complete documentation map, read the documentation site. It covers bearer and cookie clients, CSRF, roles and permissions, refresh rotation, errors, deployment, testing, versioning, and releases.
# Quick start (REST API example)
cd examples/express-rest-api
npm ci
npm start
Pass your settings to createAuth(config):
const auth = createAuth({
// Option A: Symmetric HMAC (default)
accessSecret: process.env.JWT_ACCESS_SECRET!, // String (min 32 chars)
refreshSecret: process.env.JWT_REFRESH_SECRET!, // String (min 32 chars, distinct from accessSecret)
// Option B: Asymmetric (RS256, ES256, EdDSA, PS256)
// algorithm: "ES256",
// privateKey: process.env.JWT_PRIVATE_KEY, // PKCS#8 PEM string or CryptoKey
// publicKey: process.env.JWT_PUBLIC_KEY, // SPKI PEM string or CryptoKey (required)
// keyId: "auth-key-v1", // Optional 'kid' header in JWTs
// csrfSecret: process.env.CSRF_SECRET, // Required if using cookie CSRF with asymmetric keys
//
// Or Remote JWKS Verification (Verifier-Only Mode):
// jwksUri: "https://auth.example.com/.well-known/jwks.json",
// jwks: {
// cacheMaxAge: 600_000, // 10 minutes cache
// cooldownDuration: 30_000, // 30 seconds cooldown between rate-limited refetches
// timeoutDuration: 5_000, // 5 seconds fetch timeout
// },
// Optional JWT validation policy
jwt: {
issuer: "https://api.example.com",
audience: "web-app",
clockTolerance: 5, // Allowed clock skew in seconds
},
// Optional Token Expirations (defaults shown)
accessExpiresIn: "15m", // Formats: "15m", "1h", "7d", "30d", or milliseconds
refreshExpiresIn: "7d",
// Optional Cookie Configuration
cookies: {
accessTokenName: "access_token", // Cookie name for access token
refreshTokenName: "refresh_token", // Cookie name for refresh token
options: {
httpOnly: true, // Always enforced as true for security
secure: true, // Defaults to true when NODE_ENV === 'production'
sameSite: "lax", // 'lax' | 'strict' | 'none'
path: "/", // Cookie path
domain: undefined, // Optional cookie domain
},
},
// Optional CSRF protection for cookie-authenticated requests
csrf: {
cookieName: "csrf_token", // Client-readable token cookie
headerName: "x-csrf-token", // Header copied from that cookie
methods: ["POST", "PUT", "PATCH", "DELETE"],
},
// Optional Refresh Rotation & Revocation
refreshOptions: {
rotate: false, // Set to true to enable Refresh Token Rotation
// Required when rotate: true: implement an atomic single-use check.
// consumeRefreshToken: (oldJti, ctx) => redis.set(..., { NX: true }).then(result => result === "OK"),
registerRefreshToken: async (newJti, ctx) => {},
onRefreshReuse: async (ctx) => {},
},
});
When configured, jwt.issuer and jwt.audience are included in new tokens and
must match during verification. clockTolerance allows limited clock skew in
seconds. JWT nbf claims are also validated automatically.
For mobile apps, CLI tools, or traditional REST APIs sending tokens via headers:
// Login: Return raw tokens in JSON response
app.post("/auth/login", async (req, res) => {
const tokens = await auth.generateTokenPair({
id: "user_123",
email: "user@example.com",
role: "user",
});
res.json(tokens);
// { accessToken: "eyJ...", refreshToken: "eyJ..." }
});
// Client sends: Authorization: Bearer <accessToken>
app.get("/api/data", auth.protect(), (req, res) => {
res.json({ data: "Protected content", userId: req.user.id });
});
For web applications (SPAs, React, Next.js, Vue) to protect against XSS token theft:
// Login: Sets secure HTTP-Only cookies on the response automatically
app.post("/auth/login", async (req, res) => {
const user = { id: "user_123", role: "member" };
const tokens = await auth.sendAuthTokens(res, user);
res.json({ user, tokens });
});
// Protected routes automatically read tokens from either Cookie or Authorization header
app.get("/api/data", auth.protect(), (req, res) => {
res.json({ user: req.user });
});
// Logout: Clears both cookies using the exact same path and options
app.post("/auth/logout", (req, res) => {
auth.clearAuth(res);
res.json({ message: "Logged out" });
});
Cookie-authenticated state-changing requests should include a CSRF token. Add the middleware before your routes and expose a small same-origin endpoint that sets and returns the client-readable token:
app.use(auth.csrf());
app.get("/auth/csrf-token", (req, res) => {
res.json({ csrfToken: auth.csrfToken(res) });
});
app.post("/api/profile", auth.protect(), (req, res) => {
res.json({ updated: true, user: req.user });
});
The browser sends the token in the configured header on POST, PUT, PATCH,
and DELETE requests:
const { csrfToken } = await fetch("/auth/csrf-token", {
credentials: "include",
}).then((response) => response.json());
await fetch("/api/profile", {
method: "POST",
credentials: "include",
headers: { "x-csrf-token": csrfToken },
});
Requests without auth cookies and safe methods such as GET pass through. The
middleware does not protect bearer-token requests because browsers do not send
their Authorization header automatically. Mount app.use(auth.csrf())
before state-changing routes, and keep app.use(auth.errorHandler) last.
Protect routes and limit access by user roles:
// Single role requirement
app.get("/admin", auth.protect(), auth.authorize(["admin"]), adminHandler);
// Multiple allowed roles (user must have at least one)
app.get(
"/reports",
auth.protect(),
auth.authorize(["admin", "manager", "auditor"]),
reportsHandler
);
Note:
auth.authorize()checksreq.user.role. Always placeauth.protect()beforeauth.authorize()in the middleware chain.
For fine-grained access checks, require every permission listed in the user's
permissions claim:
app.get(
"/users",
auth.protect(),
auth.authorizePermissions(["users:read"]),
listUsersHandler
);
auth.authorizePermissions() also requires auth.protect() first and returns
AUTH_FORBIDDEN when any required permission is missing.
Use auth.optional() for endpoints that serve both guests and authenticated users (e.g. public articles with personalized like buttons):
app.get("/articles/:slug", auth.optional(), (req, res) => {
if (req.user) {
// Authenticated user
res.json({ article: getArticle(), bookmarked: true });
} else {
// Guest visitor (req.user is undefined)
res.json({ article: getArticle(), bookmarked: false });
}
});
zero-auth includes a ready-to-use Express handler for refreshing tokens:
app.post("/auth/refresh", auth.refreshHandler());
Use refreshOptions.resolveUser when refreshes must reflect current account
state instead of claims stored in an older token. Return null to reject a
deleted, disabled, or unverified user:
const auth = createAuth({
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
refreshOptions: {
resolveUser: async ({ id }) => {
const user = await users.findById(id);
if (!user?.active || !user.emailVerified) return null;
return { id: user.id, email: user.email, role: user.role };
},
},
});
The returned claims replace the old application claims. The user id and refresh token family remain bound to the verified refresh token.
How the refresh token is extracted:
cookies.refreshTokenName)refreshToken or refresh_token)Authorization: Bearer <refreshToken> headerWhen refreshOptions.rotate: true is enabled, a new refresh token is issued on every refresh request, and old tokens are invalidated. If an old token is reused (indicating a stolen token), onRefreshReuse is triggered to invalidate the entire token family.
consumeRefreshToken should atomically mark the incoming jti as consumed and
return false when it was already consumed. Use a Redis SET NX or equivalent
database conditional write when the application runs on multiple instances.
You can pass a public refreshStore instead; its atomic consume() method and
optional register() / revokeFamily() methods are wired into rotation
automatically. Explicit refresh hooks still take precedence.
The legacy isRevoked + revokeRefreshToken pair remains supported for
compatibility, but logs a warning and is not concurrency-safe.
Install ioredis in your application, then use the built-in adapter. The
package does not add a Redis client dependency to your application:
npm install @0-auth/zero-auth ioredis
import Redis from "ioredis";
import {
createAuth,
createRedisRevocationStore,
} from "@0-auth/zero-auth";
const redis = new Redis(process.env.REDIS_URL);
const refreshStore = createRedisRevocationStore(redis);
const auth = createAuth({
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
refreshStore,
refreshOptions: {
rotate: true,
onRefreshReuse: async ({ familyId }) => {
if (familyId) await refreshStore.revokeFamily(familyId);
},
},
});
The adapter uses Redis SET NX for atomic single-use consumption and tracks
refresh-token families with TTLs. It accepts the ioredis-compatible client
surface without importing or bundling a Redis client. When a family is
compromised, it writes a marker before scanning known members and atomically
rejects late replacement registrations, so a concurrent replay cannot leave a
new usable refresh token behind.
In distributed architectures, sharing symmetric secrets across multiple services is a security risk. With asymmetric cryptography:
ES256 (recommended for modern web APIs), ES384, ES512RS256, RS384, RS512, PS256, PS384, PS512EdDSA (Ed25519)const auth = createAuth({
algorithm: "ES256",
privateKey: process.env.JWT_PRIVATE_KEY!, // PKCS#8 PEM string or CryptoKey
publicKey: process.env.JWT_PUBLIC_KEY!, // SPKI PEM string or CryptoKey
keyId: "auth-key-2026-v1", // Sets 'kid' in protected header
});
const tokens = await auth.generateTokenPair({ id: "user_123", role: "admin" });
const auth = createAuth({
algorithm: "ES256",
publicKey: process.env.JWT_PUBLIC_KEY!, // Only public key needed!
jwt: {
issuer: "https://auth.example.com",
audience: "api.example.com",
},
});
// Protect routes without the ability to forge or sign tokens:
app.get("/api/reports", auth.protect(), auth.authorize(["admin"]), (req, res) => {
res.json({ message: "Secure data", user: req.user });
});
Downstream APIs and microservices can dynamically verify tokens issued by central auth servers or third-party Identity Providers (Clerk, Auth0, Firebase, Google, AWS Cognito) using their public /.well-known/jwks.json endpoint.
Keys are cached in-memory and refetched automatically when a new key ID (kid) is encountered, ensuring zero downtime during key rotation.
import { createAuth } from "@0-auth/zero-auth";
const auth = createAuth({
jwksUri: "https://auth.example.com/.well-known/jwks.json",
jwt: {
issuer: "https://auth.example.com",
audience: "api.example.com",
},
jwks: {
cacheMaxAge: 600_000, // 10 min cache
cooldownDuration: 30_000, // 30 sec cooldown between fetches
timeoutDuration: 5_000, // 5 sec network timeout
},
});
// Use protect() middleware as usual:
app.get("/api/orders", auth.protect(), (req, res) => {
res.json({ orders: [], userId: req.user.id });
});
If your central service signs tokens with asymmetric keys (RS256, ES256, EdDSA), you can publish the public keys via a standard JWKS route using auth.jwksHandler():
import express from "express";
import { createAuth } from "@0-auth/zero-auth";
const auth = createAuth({
algorithm: "RS256",
keyId: "auth-key-2026-v1",
privateKey: process.env.JWT_PRIVATE_KEY!,
publicKey: process.env.JWT_PUBLIC_KEY!,
});
const app = express();
// Expose public JWKS endpoint for downstream microservices:
app.get("/.well-known/jwks.json", auth.jwksHandler());
// Or export JWKS programmatically:
const jwks = await auth.getJwks();
// { keys: [ { kty: "RSA", kid: "auth-key-2026-v1", alg: "RS256", use: "sig", n: "...", e: "AQAB" } ] }
All errors thrown by zero-auth are instances of AuthError.
// Mount after all routes
app.use(auth.errorHandler);
Default JSON error response format:
{
"error": {
"code": "AUTH_TOKEN_EXPIRED",
"message": "Token has expired",
"statusCode": 401
}
}
import { isAuthError, AuthError, AUTH_ERROR_CODES } from "@0-auth/zero-auth";
try {
const payload = await auth.verifyToken(tokenString);
} catch (err) {
if (isAuthError(err)) {
console.error(`Auth failed [${err.code}]: ${err.message} (HTTP ${err.statusCode})`);
}
}
| Code | Status Code | Description |
|---|---|---|
AUTH_TOKEN_MISSING | 401 | No authentication token found in request headers or cookies. |
AUTH_TOKEN_INVALID | 401 | Token is malformed or signature verification failed. |
AUTH_TOKEN_EXPIRED | 401 | Token has exceeded its expiration time. |
AUTH_UNAUTHORIZED | 401 | General unauthenticated error (e.g. invalid credentials or missing user state). |
AUTH_FORBIDDEN | 403 | User is authenticated but does not possess the required role or permission. |
AUTH_CSRF_INVALID | 403 | Auth cookies are present but the CSRF token is missing or invalid. |
zero-auth automatically augments Express's Request type with req.user.
import type { AuthUser } from "@0-auth/zero-auth";
interface AuthUser {
id: string;
email?: string;
role?: string;
permissions?: string[];
iat?: number;
exp?: number;
jti?: string;
fid?: string; // Token family ID (when rotation is enabled)
[key: string]: unknown; // Custom claims
}
Custom claims in your JWT payload are preserved across token refreshes and are typed on req.user:
// Sign with custom claims
await auth.generateAccessToken({
id: "user_123",
role: "admin",
tenantId: "tenant_abc",
});
// In route handler
app.get("/tenant", auth.protect(), (req, res) => {
const tenantId = req.user.tenantId; // accessible
res.json({ tenantId });
});
auth.*)Created via const auth = createAuth(config):
| Method | Return Type | Description |
|---|---|---|
generateAccessToken(payload) | Promise<string> | Generates a signed access token. |
generateRefreshToken(payload) | Promise<string> | Generates a signed refresh token. |
generateTokenPair(payload) | Promise<{ accessToken, refreshToken }> | Generates both access and refresh tokens in parallel. |
verifyToken(token) | Promise<AuthUser> | Verifies access token signature and expiration. |
verifyRefreshToken(token) | Promise<AuthUser> | Verifies refresh token signature and expiration. |
decodeToken(token) | AuthUser | Decodes a token without verifying signature. |
protect() | RequestHandler | Express middleware: rejects requests without valid access token (401). |
authorize(roles) | RequestHandler | Express middleware: ensures req.user.role is in allowed roles (403). |
authorizePermissions(permissions) | RequestHandler | Express middleware: requires every listed permission in req.user.permissions (403). |
optional() | RequestHandler | Express middleware: sets req.user if valid token present, allows guests. |
csrf() | RequestHandler | Express middleware: validates CSRF tokens on configured methods when auth cookies are present. |
sendAuthTokens(res, payload) | Promise<TokenPair> | Sets access + refresh HTTP-only cookies on res and returns tokens. |
clearAuth(res) | void | Clears access and refresh auth cookies from res. |
csrfToken(res) | string | Sets and returns a client-readable signed CSRF token. |
refreshHandler() | RequestHandler | Express route handler for refreshing access tokens. |
rotateTokens(payload) | Promise<TokenPair> | Generates a new access + refresh pair (for custom rotation logic). |
getJwks() | Promise<{ keys: Record<string, unknown>[] }> | Returns public key(s) formatted as a JSON Web Key Set (JWKS). |
jwksHandler() | RequestHandler | Express route handler that serves the JSON Web Key Set at /.well-known/jwks.json. |
errorHandler | ErrorRequestHandler | Express error middleware for handling AuthError responses. |
All core helpers are also exported directly for custom architectures:
import {
// Token Helpers
signToken,
verifyToken,
decodeToken,
// Extractors
extractToken,
extractRefreshToken,
// Cookie Utilities
setCookie,
clearCookie,
parseCookieHeader,
// Error Helpers
AuthError,
isAuthError,
authErrorHandler,
// In-Memory Revocation (Testing / Dev)
createInMemoryRevocationStore,
// Redis-backed revocation (distributed deployments)
createRedisRevocationStore,
} from "@0-auth/zero-auth";
| Utility | Description |
|---|---|
signToken(payload, secret, options) | Low-level JWT signing function using jose. |
verifyToken(token, secret) | Low-level JWT verification function. |
decodeToken(token) | Unsafely decodes token payload without signature verification. |
extractToken(req, cookieName?) | Extracts access token from Authorization header or cookie. |
extractRefreshToken(req, cookieName?) | Extracts refresh token from cookie, body, or header. |
setCookie(res, name, value, options) | Sets an HTTP-only cookie with secure defaults. |
clearCookie(res, name, options) | Clears a cookie matching its path/domain/sameSite settings. |
parseCookieHeader(cookieHeader) | Zero-dependency cookie string parser. |
createInMemoryRevocationStore() | In-memory token revocation helper for development and tests. |
createRedisRevocationStore(redis, ttlSeconds?) | ioredis-compatible store for atomic rotation and family revocation. |
createJwksResolver(jwksUri, options?) | Creates a remote JWKS resolver function for low-level verifyToken. |
exportPublicKeyToJwk(publicKey, options?) | Converts SPKI PEM or KeyLike public key to a standard JWK object. |
accessSecret and refreshSecret values with a minimum length of 32 characters.node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
NODE_ENV=production), secure: true is automatically enabled on cookies so tokens are only transmitted over HTTPS.auth.csrf() for cookie-authenticated state-changing requests and send the token in the configured header.accessExpiresIn short (15m recommended).7d - 30d).jti identifiers when refreshOptions.rotate: true is enabled.| Environment | Supported | Notes |
|---|---|---|
| Node.js | >= 18.0.0 | Full ESM & CommonJS support |
| Express | 4.x & 5.x | Peer dependency for middleware |
| Bun | >= 1.0.0 | Native runtime support |
| TypeScript | >= 5.0.0 | Declarations bundled |
| Serverless / Edge | ✅ | Powered by jose (Web Crypto API compliant) |
The npm tarball includes the README, CHANGELOG, license, compiled ESM/CommonJS
builds, and TypeScript declarations. The full guide and generated API site are
built from the repository's docs/ source.
Found a bug or have an idea? Open an issue or read the contribution guide.
MIT © zero-auth contributors
Download counts and popularity will appear here as the collector receives them.
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 21:53 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:a4d411f9e361dedbd741154463741d73d3641f6efa1dd4f53456353d3c221ec9