@gablura/auth-core
Production-ready authentication core for Express.js backends.
@gablura/auth-core
Production-ready authentication infrastructure for full-stack applications.
@gablura/auth-core is the backend foundation of the Gablura auth system. It provides a complete authentication pipeline — token exchange, session binding, refresh rotation, account lockout, 2FA, and audit logging — as a single, configurable module.
Why it exists
Most developers face the same authentication problems repeatedly:
- Implementing secure token storage and rotation
- Preventing session hijacking across devices
- Handling account lockout after failed attempts
- Adding 2FA without rewriting the entire auth layer
- Maintaining audit logs for compliance
@gablura/auth-core solves these problems once, so you can focus on building your application.
Features
- Dual-token architecture — Access + refresh tokens with RS256 signing
- Token rotation — Automatic refresh token rotation with reuse detection
- Session binding — Device fingerprint + IP validation to prevent hijacking
- Account lockout — Configurable failed attempt thresholds with automatic unlock
- Two-factor authentication — TOTP-based 2FA with backup codes
- Audit logging — Structured auth event logging with severity levels
- Redis-backed — Session management, caching, and revocation via Redis
- Express middleware — Drop-in middleware factory for Express applications
- Cache layer — Optional auth result and user profile caching
- Idempotent exchanges — Duplicate request protection for token exchange
Architecture
Browser
↓
NextAuth (HMAC proof)
↓
@gablura/auth-core
↓
Express Backend
↓
Redis + MongoDB
The core module sits between your auth provider (NextAuth, custom JWT, etc.) and your backend. It validates proofs, manages tokens, enforces sessions, and logs everything.
Internal modules
TokenManager — RS256 JWT signing and verification
TokenRevocation — Refresh token rotation with atomic Redis operations
SessionManager — Session tracking with LRU eviction
SessionBinding — Device fingerprint + IP validation
AccountLockout — Failed attempt tracking with auto-unlock
AuditLog — Structured event logging
MiddlewareFactory — Express middleware composition
Each module is independently configurable and can be extended or replaced.
Installation
Requirements
- Node.js >= 22
- Redis >= 6 (for session management and caching)
- Express 4 or 5 (for middleware integration)
Install
npm install @gablura/auth-core ioredis
pnpm add @gablura/auth-core ioredis
yarn add @gablura/auth-core ioredis
Peer dependencies
| Package | Version | Required |
|---|---|---|
ioredis | >= 5 | Yes |
express | 4 or 5 | For middleware |
RSA key generation
@gablura/auth-core uses RS256 (RSA + SHA-256) for JWT signing. You need to generate an RSA key pair.
# Generate private key
openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:2048
# Extract public key
openssl rsa -pubout -in private-key.pem -out public-key.pem
Store these securely. Never commit them to version control.
Environment variables
# Required
REDIS_URL=redis://localhost:6379
AUTH_PRIVATE_KEY=path/to/private-key.pem
AUTH_PUBLIC_KEY=path/to/public-key.pem
# Optional
AUTH_SESSION_TTL=1800
AUTH_REFRESH_TTL=2592000
AUTH_LOCKOUT_THRESHOLD=5
AUTH_LOCKOUT_WINDOW=900
Quick Start
1. Create the auth service
import { AuthService } from "@gablura/auth-core";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const auth = new AuthService({
privateKey: process.env.AUTH_PRIVATE_KEY!,
publicKey: process.env.AUTH_PUBLIC_KEY!,
redis,
});
2. Set up Express middleware
import express from "express";
import { MiddlewareFactory } from "@gablura/auth-core";
const app = express();
const mw = new MiddlewareFactory(auth);
app.use(mw.verifyToken());
3. Exchange a token
// After NextAuth (or custom auth) issues an HMAC proof
app.post("/api/auth/exchange", async (req, res) => {
const result = await auth.exchange({
proof: req.body.proof,
userId: req.body.userId,
deviceId: req.body.deviceId,
});
if (result.ok) {
res.json({ accessToken: result.accessToken, refreshToken: result.refreshToken });
} else {
res.status(401).json({ error: result.error });
}
});
4. Verify a token
app.get("/api/protected", mw.verifyToken(), (req, res) => {
res.json({ message: "Authenticated", userId: req.auth.userId });
});
5. Refresh a token
app.post("/api/auth/refresh", async (req, res) => {
const result = await auth.refresh({
refreshToken: req.body.refreshToken,
deviceId: req.body.deviceId,
});
if (result.ok) {
res.json({ accessToken: result.accessToken, refreshToken: result.refreshToken });
} else {
res.status(401).json({ error: result.error });
}
});
That's the basic flow. See Configuration for all options and Usage for advanced patterns.
Configuration
AuthCoreConfig
The AuthService constructor accepts a configuration object:
const auth = new AuthService({
privateKey: "path/to/private-key.pem",
publicKey: "path/to/public-key.pem",
redis,
// ... optional config
});
Required options
| Option | Type | Description |
|---|---|---|
privateKey | string | Path to RSA private key (PEM format) |
publicKey | string | Path to RSA public key (PEM format) |
redis | Redis | ioredis instance for session/cache storage |
Optional options
| Option | Type | Default | Description |
|---|---|---|---|
sessionTTL | number | 1800 | Access token lifetime in seconds (30 min) |
refreshTTL | number | 2592000 | Refresh token lifetime in seconds (30 days) |
lockoutThreshold | number | 5 | Failed attempts before lockout |
lockoutWindow | number | 900 | Lockout duration in seconds (15 min) |
cacheEnabled | boolean | false | Enable auth result caching |
cacheAuthTTL | number | 300 | Auth cache TTL in seconds (5 min) |
cacheUserTTL | number | 1800 | User profile cache TTL in seconds (30 min) |
maxConcurrentSessions | number | 10 | Max sessions per user |
keyPrefix | string | auth: | Redis key prefix |
serverSecret | string | — | Secret for server-to-server binding bypass |
trustedProxies | string[] | [] | CIDR ranges for X-Forwarded-For validation |
Defaults
const DEFAULTS = {
sessionTTL: 1800, // 30 minutes
refreshTTL: 2592000, // 30 days
lockoutThreshold: 5,
lockoutWindow: 900, // 15 minutes
cacheEnabled: false,
cacheAuthTTL: 300, // 5 minutes
cacheUserTTL: 1800, // 30 minutes
maxConcurrentSessions: 10,
keyPrefix: "auth:",
};
Resolving configuration
Use resolveConfig to merge user options with defaults:
import { resolveConfig } from "@gablura/auth-core";
const config = resolveConfig({
privateKey: process.env.AUTH_PRIVATE_KEY!,
publicKey: process.env.AUTH_PUBLIC_KEY!,
redis,
lockoutThreshold: 10, // override default
});
Usage
API hierarchy
@gablura/auth-core organizes its API into three tiers:
Primary API
Daily-use operations:
auth.exchange({ proof, userId, deviceId }) // Exchange HMAC proof for tokens
auth.verify({ token, deviceId }) // Verify access token
auth.refresh({ refreshToken, deviceId }) // Rotate refresh token
auth.logout({ refreshToken }) // Revoke refresh token
auth.enable2FA({ userId }) // Generate 2FA secret
auth.verify2FA({ userId, code }) // Verify TOTP code
auth.getSessions({ userId }) // List active sessions
auth.revokeSession({ sessionId }) // Revoke a single session
auth.revokeAllSessions({ userId }) // Revoke all sessions for user
Extension API
Additional utilities:
auth.recordLoginFailure({ userId }) // Record failed login attempt
auth.resetLoginFailures({ userId }) // Clear failed login attempts
auth.checkLockout({ userId }) // Check if account is locked
auth.getAuditLog({ userId, limit }) // Retrieve audit events
Advanced API
Internal access when you need it:
auth.getTokenManager() // Direct TokenManager access
auth.getTokenRevocation() // Direct TokenRevocation access
auth.getSessionManager() // Direct SessionManager access
auth.getAccountLockout() // Direct AccountLockout access
auth.getAuditLog() // Direct AuditLog access
auth.getRedis() // Direct Redis access
auth.getConfig() // Current resolved config
auth.invalidateUserCache({ userId }) // Clear user cache
Express middleware
import { MiddlewareFactory } from "@gablura/auth-core";
const mw = new MiddlewareFactory(auth);
// Require valid token
app.use(mw.verifyToken());
// Optional token (sets req.auth if valid, continues if not)
app.use(mw.optionalToken());
// Require CSRF validation
app.use(mw.verifyCsrf());
// Rate limiting
app.use(mw.rateLimit({ windowMs: 60000, max: 100 }));
// Session timeout
app.use(mw.sessionTimeout());
Adapters
Implement these interfaces to connect to your data store:
UserStore
interface UserStore {
findUserById(id: string): Promise<User | null>;
findUserByEmail(email: string): Promise<User | null>;
}
CacheAdapter
interface CacheAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttl?: number): Promise<void>;
del(key: string): Promise<void>;
}
RedisAdapter
The built-in Redis adapter handles all session and cache operations. Use it unless you need a custom implementation.
API Reference
AuthService
exchange(params)
Exchange an HMAC proof for token pair.
auth.exchange({
proof: string, // HMAC proof from auth provider
userId: string, // User identifier
deviceId: string, // Device identifier
}): Promise<ExchangeResult>
ExchangeResult:
{ ok: true, accessToken: string, refreshToken: string }
| { ok: false, error: string }
verify(params)
Verify an access token.
auth.verify({
token: string, // Access token JWT
deviceId: string, // Expected device ID
}): Promise<VerifyResult>
VerifyResult:
{ ok: true, userId: string, sessionId: string }
| { ok: false, error: string }
refresh(params)
Rotate a refresh token and issue new pair.
auth.refresh({
refreshToken: string, // Current refresh token
deviceId: string, // Device identifier
}): Promise<RefreshResult>
logout(params)
Revoke a refresh token.
auth.logout({
refreshToken: string,
}): Promise<{ ok: boolean }>
enable2FA(params)
Generate a TOTP secret for 2FA enrollment.
auth.enable2FA({
userId: string,
}): Promise<{ secret: string, backupCodes: string[] }>
verify2FA(params)
Verify a TOTP code.
auth.verify2FA({
userId: string,
code: string, // 6-digit TOTP code
}): Promise<{ ok: boolean }>
getSessions(params)
List active sessions for a user.
auth.getSessions({
userId: string,
}): Promise<Session[]>
revokeSession(params)
Revoke a single session.
auth.revokeSession({
sessionId: string,
}): Promise<{ ok: boolean }>
revokeAllSessions(params)
Revoke all sessions for a user.
auth.revokeAllSessions({
userId: string,
}): Promise<{ ok: boolean }>
Error classes
| Class | Status | Error Code |
|---|---|---|
AuthError | 401 | AUTH_ERROR |
TokenExpiredError | 401 | TOKEN_EXPIRED |
TokenRevokedError | 401 | TOKEN_REVOKED |
SessionBindingError | 401 | SESSION_BINDING_FAILED |
AccountLockedError | 423 | ACCOUNT_LOCKED |
InvalidProofError | 401 | INVALID_PROOF |
RateLimitError | 429 | RATE_LIMIT_EXCEEDED |
Examples
Express integration
import express from "express";
import { AuthService, MiddlewareFactory } from "@gablura/auth-core";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const auth = new AuthService({
privateKey: process.env.AUTH_PRIVATE_KEY!,
publicKey: process.env.AUTH_PUBLIC_KEY!,
redis,
cacheEnabled: true,
});
const app = express();
const mw = new MiddlewareFactory(auth);
app.use(express.json());
// Protected route
app.get("/api/profile", mw.verifyToken(), (req, res) => {
res.json({ userId: req.auth.userId, sessionId: req.auth.sessionId });
});
// Token exchange
app.post("/api/auth/exchange", async (req, res) => {
try {
const result = await auth.exchange({
proof: req.body.proof,
userId: req.body.userId,
deviceId: req.body.deviceId,
});
if (result.ok) {
res.json({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
} else {
res.status(401).json({ error: result.error });
}
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
});
// Refresh
app.post("/api/auth/refresh", async (req, res) => {
const result = await auth.refresh({
refreshToken: req.body.refreshToken,
deviceId: req.body.deviceId,
});
if (result.ok) {
res.json({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
} else {
res.status(401).json({ error: result.error });
}
});
Error handling
import { AuthError, TokenExpiredError, AccountLockedError } from "@gablura/auth-core";
try {
const result = await auth.verify({ token, deviceId });
if (!result.ok) {
// Handle specific errors
if (result.error === "TOKEN_EXPIRED") {
// Prompt refresh
}
}
} catch (err) {
if (err instanceof AccountLockedError) {
// Show lockout message
} else if (err instanceof AuthError) {
// Generic auth error
}
}
Custom error factory
const auth = new AuthService({
privateKey: process.env.AUTH_PRIVATE_KEY!,
publicKey: process.env.AUTH_PUBLIC_KEY!,
redis,
errorFactory: (code, message) => {
// Custom error formatting
return new AppError(code, message);
},
});
Security
Token architecture
- RS256 signing — RSA + SHA-256 for JWT integrity
- Token rotation — Refresh tokens are single-use; each refresh issues a new pair
- Reuse detection — If a revoked refresh token is presented, all sessions for that user are revoked
- Idempotent exchanges — Duplicate token exchange requests return the same tokens
Session binding
Sessions are bound to a device fingerprint + IP address:
- Device fingerprint — Derived from User-Agent headers with normalization
- IP validation — Compares request IP against session IP
- Private IP detection — Handles local/private network addresses correctly
- Trusted proxies — CIDR-aware X-Forwarded-For validation for reverse proxy setups
const auth = new AuthService({
// ...
trustedProxies: ["10.0.0.0/8", "172.16.0.0/12"],
});
Server-to-server binding
For backend-to-backend calls where device fingerprints aren't available:
const auth = new AuthService({
// ...
serverSecret: process.env.AUTH_SERVER_SECRET,
});
When serverSecret is set, requests with a valid HMAC proof from the server secret bypass device fingerprint validation.
Account lockout
Configurable brute-force protection:
const auth = new AuthService({
// ...
lockoutThreshold: 5, // Lock after 5 failures
lockoutWindow: 900, // Lock for 15 minutes
});
CSRF protection
app.use(mw.verifyCsrf());
Validates CSRF tokens using timing-safe comparison.
Rate limiting
app.use(mw.rateLimit({
windowMs: 60000, // 1 minute window
max: 100, // 100 requests per window
}));
Timing-safe comparisons
All HMAC and CSRF token comparisons use crypto.timingSafeEqual to prevent timing attacks.
Performance
Caching
When cacheEnabled: true, the module caches:
| Data | TTL | Purpose |
|---|---|---|
| Auth results | 5 min | Avoid repeated token verification |
| User profiles | 30 min | Reduce database lookups |
| Refresh dedup | 30 sec | Prevent concurrent refresh races |
Redis operations
- Atomic Lua scripts — Token rotation uses a single Lua script for atomicity
- Distributed locks — Prevents concurrent refresh token rotations
- Pipeline operations — Lockout tracking uses Redis pipelines
- LRU eviction — Max concurrent sessions enforced with least-recently-used eviction
Tuning
Increase cache TTL
For high-traffic applications:
const auth = new AuthService({
// ...
cacheEnabled: true,
cacheAuthTTL: 600, // 10 minutes
cacheUserTTL: 3600, // 1 hour
});
Adjust session limits
const auth = new AuthService({
// ...
maxConcurrentSessions: 20, // Allow more concurrent sessions
});
Redis memory
Each session uses approximately 200-500 bytes in Redis. For 10,000 concurrent sessions, plan for ~5MB of Redis memory for auth data.
FAQ
Can I use this without NextAuth?
Yes. @gablura/auth-core is backend-only. You can use any auth provider that can issue an HMAC proof, or build your own.
Can I use this without Express?
Yes. The AuthService class is framework-agnostic. You can use it with Fastify, Hono, or any Node.js HTTP server. The MiddlewareFactory is Express-specific, but the core service works everywhere.
What Redis versions are supported?
Redis 6 and above. The module uses features like Lua scripting and pipelines that are available in all modern Redis versions.
Can I use this with App Router?
Yes. @gablura/auth-next provides App Router support. @gablura/auth-core works with any Next.js routing model.
How do I rotate RSA keys?
Generate new keys and update the environment variables. Existing tokens signed with the old key will remain valid until they expire. New tokens will use the new key.
What happens if Redis goes down?
The behavior depends on your configuration:
- Without cache: Auth still works, but session management and revocation stop working
- With cache: Cached results continue to work, but new verifications hit the database
How do I handle deployment?
During deployment, existing sessions remain valid as long as Redis is persistent. If you restart Redis, all sessions are lost and users must re-authenticate.
Can I use this with MongoDB?
Yes. Implement the UserStore interface to connect to MongoDB. The module doesn't assume any specific database.
Changelog
1.2.4
- Fix refresh deduplication race condition
- Add
serverSecretfor server-to-server binding bypass - Add
trustedProxiesfor CIDR-aware IP validation
1.2.3
- Add
maxConcurrentSessionswith LRU eviction - Add
keyPrefixconfiguration - Improve audit log structured output
1.2.2
- Add idempotency keys for token exchange
- Add refresh deduplication with 30-second TTL
- Fix session binding for private IP addresses
1.2.1
- Add
cacheEnabledoption - Add user profile caching with 30-minute TTL
- Add auth result caching with 5-minute TTL
1.2.0
- Add 2FA support (TOTP + backup codes)
- Add session management API
- Add account lockout
- Add audit logging
1.1.0
- Add session binding (device fingerprint + IP)
- Add CSRF protection
- Add rate limiting middleware
1.0.0
- Initial release
- Dual-token architecture (access + refresh)
- RS256 JWT signing
- Token rotation with reuse detection
- Express middleware factory
- Redis adapter
Contributing
Development setup
git clone https://github.com/gablura/auth.git
cd auth
pnpm install
Project structure
packages/
├── core/ # @gablura/auth-core
│ ├── src/
│ └── package.json
└── next/ # @gablura/auth-next
├── src/
└── package.json
Scripts
pnpm test # Run tests
pnpm build # Build all packages
pnpm lint # Lint all packages
Pull requests
- Create a feature branch from
main - Make your changes
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
Code style
- TypeScript strict mode
- ESLint with the project config
- Prettier for formatting
Testing
pnpm test # Run all tests
pnpm test:watch # Watch mode
License
By contributing, you agree that your contributions will be licensed under the MIT License.
License
MIT License
Copyright 2026 Gablura
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.