JWT Authentication
createJwtPlugin() returns a self-contained authentication plugin built on Node.js crypto — no external JWT library is required.
Features: HMAC / RSA / ECDSA algorithms, refresh token rotation, role and permission guards, pluggable user database, pluggable token store.
Quick start
import { createRouter, json, createJwtPlugin } from 'expediate';
const app = createRouter();
const auth = createJwtPlugin({
accessTokenSecret: process.env.JWT_SECRET!,
});
// Auth endpoints (all require the json() body parser)
app.post('/auth/login', json(), auth.login);
app.post('/auth/refresh', json(), auth.refresh);
app.post('/auth/logout', json(), auth.logout);
// Protected route
app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
res.json((req as any).user);
});Auth endpoints
POST /auth/login
// Request
{ "username": "alice", "password": "password123" }
// Response 200
{
"accessToken": "eyJ...",
"refreshToken": "a3f8...",
"expiresIn": 900,
"tokenType": "Bearer"
}POST /auth/refresh
// Request
{ "username": "alice", "refreshToken": "a3f8..." }
// Response 200 — new token pair (old refresh token is invalidated immediately)
{ "accessToken": "eyJ...", "refreshToken": "b9c2...", "expiresIn": 900, "tokenType": "Bearer" }Refresh tokens are rotated on every use — the old token is invalidated before the new pair is issued, so a stolen refresh token can only be used once.
POST /auth/logout
// Request
{ "refreshToken": "b9c2..." }
// Response 200 — refresh token revoked
{ "message": "Logged out successfully" }Protecting routes
// authenticate — populates req.user; calls next() silently on failure
// authorize — rejects with 401 Unauthorized if req.user is not set
app.get('/profile', auth.authenticate, auth.authorize, handler);authenticate always clears req.user at the start of the middleware call to prevent stale data leaking across requests. This split design means you can optionally inspect req.user in middleware before requiring it.
req.user shape when authenticated:
interface TokenPayload {
sub: string; // user ID
username: string;
iss: string; // issuer
iat: number; // issued at (Unix seconds)
exp: number; // expires at (Unix seconds)
roles?: string[];
permissions?: string[];
}Role and permission guards
Both helpers return [authenticate, guard] — spread them into route registration:
// Require at least ONE of the listed roles
app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
app.get('/reports', ...auth.requireRole('admin', 'editor'), getReports);
// Require ALL of the listed permissions
app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);Configuration reference
const auth = createJwtPlugin({
// ── Secrets (always set in production) ──────────────────────────────────
accessTokenSecret: 'change-me', // shared HMAC secret (HS*)
// For asymmetric algorithms (RS*, ES*), supply PEM keys instead:
// accessTokenPrivateKey: readFileSync('private.pem', 'utf8'),
// accessTokenPublicKey: readFileSync('public.pem', 'utf8'),
// ── Algorithm ───────────────────────────────────────────────────────────
alg: 'HS256', // 'HS256' | 'HS384' | 'HS512'
// 'RS256' | 'RS384' | 'RS512'
// 'ES256' | 'ES384' | 'ES512'
// ── Expiry ──────────────────────────────────────────────────────────────
accessTokenExpiry: 15 * 60, // 15 minutes (seconds)
refreshTokenExpiry: 7 * 24 * 3600, // 7 days (seconds)
// ── Claims ──────────────────────────────────────────────────────────────
issuer: 'my-app',
checkIssuer: true, // reject tokens with a different iss claim
// ── User database ───────────────────────────────────────────────────────
// Replace with a real database query; return null to reject login
fetchUser: async (username) => {
return await db.users.findOne({ username });
},
// ── Password validation ─────────────────────────────────────────────────
// Default uses SHA-256 — replace with bcrypt/argon2 for production
isPasswordValid: async (user, password) => {
return await bcrypt.compare(password, user.passwordHash);
},
// ── Custom JWT payload ──────────────────────────────────────────────────
payload: (user) => ({
sub: user.id,
username: user.username,
roles: user.roles,
permissions: user.permissions,
}),
// ── Token store ─────────────────────────────────────────────────────────
// Default is an in-memory Map. Replace with a Redis adapter for multi-instance.
refreshTokenStore: redisAdapter,
});Token store interface
interface TokenStore {
get(token: string): Promise<RefreshTokenRecord | undefined>;
set(token: string, record: RefreshTokenRecord): Promise<void>;
delete(token: string): Promise<void>;
}
interface RefreshTokenRecord {
username: string;
expiresAt: number; // Unix seconds
}A built-in in-memory store is available for testing:
import { createMapTokenStore } from 'expediate';
const auth = createJwtPlugin({
accessTokenSecret: 'dev-secret',
refreshTokenStore: createMapTokenStore(),
});Algorithms
All algorithms are implemented using Node.js crypto — no third-party JWT library:
| Algorithm | Key type | Notes |
|---|---|---|
HS256 / HS384 / HS512 |
Shared HMAC secret string | Default; accessTokenSecret |
RS256 / RS384 / RS512 |
RSA PEM key pair | accessTokenPrivateKey + accessTokenPublicKey |
ES256 / ES384 / ES512 |
ECDSA PEM key pair | DER↔P1363 conversion for standard JWT wire format |
Verification uses crypto.timingSafeEqual to prevent timing attacks.
Security notes
- The default password hashing uses SHA-256, which is not suitable for production. Always supply an
isPasswordValidfunction that uses bcrypt, argon2, or scrypt. - Calling
createJwtPlugin()with noaccessTokenSecretuses a placeholder secret and should only be done in tests or demos. - Refresh tokens are opaque 128-character hex strings (64 random bytes), not JWTs. They are stored server-side in the token store and invalidated on each use.