A batteries-included, production-ready PASETO v4 authentication engine for Node.js.
JSON Web Tokens (JWT) are ubiquitous, but they are fundamentally flawed. They place the burden of cryptographic decision-making on developers, leading to massive security vulnerabilities like "algorithm confusion" attacks (alg: none). Furthermore, JWTs are completely stateless by default. This makes proper token revocation (e.g., logging out a compromised device or banning a user instantly) impossible without building complex infrastructure from scratch.
SecureAuth SDK solves this. Built entirely on PASETO v4 (Platform-Agnostic Security Tokens), it completely eliminates algorithm confusion by making cryptography structural rather than configurable. More importantly, SecureAuth provides a batteries-included Redis-backed stateful layer, offering instant token revocation, HTTP-only refresh token rotation, and strict device fingerprinting out of the box.
| Feature | Standard JWT | Raw PASETO | SecureAuth SDK |
|---|---|---|---|
| Cryptography | Configurable (Dangerous) | Fixed (Secure) | Fixed (Secure) |
| Algorithm Confusion | Vulnerable (alg: none) |
Immune | Immune |
| Revocation | Impossible / DIY | Impossible / DIY | Instant (Redis-backed) |
| Refresh Rotation | DIY | DIY | Automatic & Transparent |
| Rate Limiting | DIY | DIY | Atomic (Redis Lua Scripts) |
| Token Replay Attacks | Vulnerable if stolen | Vulnerable if stolen | Blocked (Device Fingerprinting) |
Install the SecureAuth server package and peer dependencies:
npm install secure-auth-server ioredis express cookie-parser
(Note: SecureAuth relies on Redis to manage real-time revocation and session state).
SecureAuth uses Ed25519 asymmetric cryptography. Generate your secure key pair:
npx tsx -e "import { SecureAuth } from 'secure-auth-server'; console.log(SecureAuth.generateKeys());"Copy the generated keys into your .env file:
PASETO_PRIVATE_KEY=302e... PASETO_PUBLIC_KEY=302a... REDIS_URL=redis://localhost:6379
Here is a complete, working example using Express:
import express from 'express'; import cookieParser from 'cookie-parser'; import { SecureAuth } from 'secure-auth-server'; const app = express(); app.use(express.json()); app.use(cookieParser()); // Required for refresh token rotation // 1. Initialize the Engine const auth = new SecureAuth({ redis: { url: process.env.REDIS_URL! }, keys: { private: process.env.PASETO_PRIVATE_KEY!, public: process.env.PASETO_PUBLIC_KEY!, }, tokenTTL: 900, // Access tokens expire in 15 mins refreshTTL: 604800, // Refresh tokens expire in 7 days bindingMode: 'ua-only', // Binds tokens to the browser's User-Agent onRedisError: 'fail-open', // Allow auth when Redis is down cookieOptions: { secure: true, // Set to false for local dev without HTTPS sameSite: 'strict', domain: '.example.com' // Optional: share cookies across subdomains } }); // 2. Add Auto-Managed Routes // Mounts POST /auth/refresh, POST /auth/logout, and POST /auth/logout/all app.use('/auth', auth.router()); // 3. Login Endpoint (Your custom logic) app.post('/login', async (req, res) => { const { username, password } = req.body; // Verify user credentials against your DB... if (username === 'alice' && password === 'secret') { const userId = 'user_123'; const roles = ['user']; // Issue tokens: passing `res` automatically sets the refresh token // as an httpOnly cookie with the SDK's secure configuration. const tokens = await auth.issueTokens(userId, roles, req, res); res.json({ accessToken: tokens.accessToken, expiresAt: tokens.accessTokenExpiresAt }); } else { res.status(401).json({ error: 'Invalid credentials' }); } }); // 4. Protected Route // auth.verify() checks signature, expiration, Redis revocation, and fingerprint app.get('/api/protected', auth.verify(), (req, res) => { // Access validated user details from req.auth res.json({ message: 'Welcome to the protected zone!', userId: req.auth!.sub, roles: req.auth!.roles }); }); app.listen(3000, () => console.log('SecureAuth server running on port 3000'));
Note: Passing
restoissueTokens()is optional. If omitted, you can set the cookie manually usingauth.setRefreshCookie(res, tokens.refreshToken). The auto-cookie approach is recommended to avoid duplicating cookie configuration.
The auth.verify() middleware performs a 4-step validation chain on every request:
Bearer Token → Ed25519 Signature Check → Expiry/nbf Check → Redis Revocation Check → Fingerprint Match
- Signature Verification: Uses the PASETO library to verify the Ed25519 signature. A tampered token is rejected immediately.
- Time Claims: Checks
exp(expiry) andnbf(not-before). An expired or future token is rejected. - Revocation Check: Queries Redis for
revoked:{jti}. If the key exists, the token has been revoked vialogout(). - Fingerprint Match: Computes
sha256(userAgent)from the request and compares to thefpclaim in the token. A mismatch indicates the token was stolen and replayed from a different device.
If all checks pass, req.auth is set with the decoded identity:
interface AuthContext { sub: string; // User ID jti: string; // Unique token ID roles: string[]; // User roles fp: string; // Device fingerprint }
| Option | Type | Default | Description |
|---|---|---|---|
redis.url |
string |
required | Redis connection URL (e.g., redis://localhost:6379) |
keys.private |
string |
required | Ed25519 private key (PKCS#8 DER, hex-encoded) |
keys.public |
string |
required | Ed25519 public key (SPKI DER, hex-encoded) |
tokenTTL |
number |
900 |
Access token lifetime in seconds (15 minutes) |
refreshTTL |
number |
604800 |
Refresh token lifetime in seconds (7 days) |
bindingMode |
string |
'ua-only' |
Device fingerprint binding mode (see below) |
onRedisError |
string |
'fail-open' |
Strategy when Redis is down: 'fail-open' (allow auth but disable revocation checks) or 'fail-closed' (block auth completely). |
cookieOptions |
object |
{ secure: true, ... } |
Override default secure cookie settings for refresh tokens (e.g. domain, secure). |
SecureAuth prevents stolen tokens from being used on different devices. Configure the bindingMode during initialization:
| Mode | Fingerprint Formula | Use Case |
|---|---|---|
'strict' |
sha256(ip + userAgent) |
High-security apps. Rejects if IP or UA changes. |
'ua-only' |
sha256(userAgent) |
Recommended. Tolerates IP changes (mobile networks). |
'none' |
Disabled | Testing or apps where replay protection is handled elsewhere. |
If a user account is compromised, you can instantly kill all their active sessions across all devices from your backend:
await auth.revokeUser('user_123');
This revokes all access tokens (via Redis JTI blocklist) and all refresh tokens simultaneously.
List all active sessions for the authenticated user:
GET /auth/sessions
Authorization: Bearer <access_token>
Returns:
{
"sessions": [
{ "jti": "abc123...", "expiresAt": "2026-07-25T15:30:00.000Z" },
{ "jti": "def456...", "expiresAt": "2026-07-25T16:00:00.000Z" }
]
}const isHealthy = await auth.healthCheck(); // true if Redis is reachable
All error responses follow a consistent format:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description"
}
}| Code | HTTP Status | Meaning |
|---|---|---|
MISSING_TOKEN |
401 | No Authorization: Bearer header provided |
TOKEN_INVALID |
401 | PASETO signature is invalid (tampered or wrong key) |
TOKEN_EXPIRED |
401 | Access token's exp claim is in the past |
TOKEN_REVOKED |
401 | Token's jti is in the Redis revocation blocklist |
FINGERPRINT_MISMATCH |
401 | Request device fingerprint doesn't match token's fp claim |
REFRESH_TOKEN_INVALID |
401 | Refresh token is missing, expired, or not found in Redis |
RATE_LIMIT_EXCEEDED |
429 | Too many requests — includes Retry-After header |
VALIDATION_ERROR |
400 | Request body failed Zod schema validation |
SERVICE_UNAVAILABLE |
503 | Redis is down and onRedisError is set to 'fail-closed' |
KEY_LOADING_ERROR |
500 | Server misconfiguration: Ed25519 keys are invalid or missing |
INTERNAL_ERROR |
500 | Unexpected server error (details never leaked to client) |
Startup-only errors:
CONFIGURATION_ERRORandKEY_LOADING_ERRORare thrown synchronously bynew SecureAuth(...)when the SDK is misconfigured (invalid TTLs, bad enum values, insecure cookie combinations, or non-Ed25519 keys). They are never returned as HTTP responses — the server fails fast at boot instead.
When you call app.use('/auth', auth.router()), the SDK mounts these routes:
| Route | Method | Auth Required | Description |
|---|---|---|---|
/auth/refresh |
POST | Cookie | Rotates refresh token, issues new access token |
/auth/logout |
POST | Bearer + Cookie | Revokes current access + refresh token |
/auth/logout/all |
POST | Bearer | Revokes all tokens for the user across all devices |
/auth/sessions |
GET | Bearer | Lists all active sessions for the user |
secureauth-sdk/
├── packages/
│ ├── core/ # Shared types, errors, and utilities
│ └── server/ # PASETO engine, Redis services, middleware
├── apps/
│ └── example-express/ # Working reference implementation
├── scripts/
│ └── keygen.ts # Ed25519 key pair generator
└── SECURITY.md # Security policy and design principles
MIT License — see LICENSE for details.