JWT + refresh token rotation, RBAC, audit logging, token-bucket rate limiting, WAF, and webhook HMAC verification.
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
// Issue token pair
async function issueTokens(user) {
const accessToken = jwt.sign(
{
sub: user._id,
role: user.role,
tenantId: user.tenantId,
},
process.env.JWT_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
const refreshTokenId = uuidv4();
const refreshToken = jwt.sign(
{ sub: user._id, jti: refreshTokenId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '30d', algorithm: 'HS256' }
);
// Store refresh token ID in Redis (TTL = 30 days)
await redis.setex(`refresh:${refreshTokenId}`, 30 * 24 * 3600, user._id.toString());
return { accessToken, refreshToken };
}
// Rotate: validate old, issue new, invalidate old
async function rotateTokens(oldRefreshToken) {
let payload;
try {
payload = jwt.verify(oldRefreshToken, process.env.JWT_REFRESH_SECRET);
} catch {
throw new UnauthorizedError('Invalid refresh token');
}
// Check Redis: is this token still valid?
const userId = await redis.get(`refresh:${payload.jti}`);
if (!userId) throw new UnauthorizedError('Refresh token revoked or expired');
// Invalidate old token immediately (rotation — one-time use)
await redis.del(`refresh:${payload.jti}`);
const user = await User.findById(userId);
return issueTokens(user); // issue fresh pair
}
Q: What happens if a refresh token is stolen and used by an attacker?
A: Token rotation detects this. When the attacker uses the stolen token, the server issues a new pair and invalidates the old one. When the legitimate user then tries to refresh with their (now invalidated) token, the server detects the double-use — both the attacker's and user's tokens are now invalid. The server should then force re-login. This is the refresh token rotation security guarantee: any stolen token can only be used once before detection.
Q: How do you handle logout?
A: On logout, we delete the refresh token from Redis (DEL refresh:{jti}). The access token remains technically valid until it expires in 15 minutes, but since it's short-lived, this window is acceptable. For forced logout (admin banning a user), we add the access token's jti to a Redis blacklist with TTL matching its remaining lifetime.
const AuditLogSchema = new Schema({
tenantId: { type: ObjectId, required: true, index: true },
userId: { type: ObjectId, required: true },
action: { type: String, required: true }, // e.g. 'order.status.update'
resource: { type: String, required: true }, // e.g. 'Order'
resourceId: { type: ObjectId, required: true },
ip: String,
userAgent: String,
diff: { // what changed
before: Schema.Types.Mixed,
after: Schema.Types.Mixed,
},
timestamp: { type: Date, default: Date.now, index: true },
});
// TTL: auto-delete logs older than 1 year
AuditLogSchema.index({ timestamp: 1 }, { expireAfterSeconds: 365 * 24 * 3600 });
// Middleware to auto-log changes
function auditMiddleware(action, resource) {
return async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode < 400 && req.user) {
AuditLog.create({
tenantId: req.user.tenantId,
userId: req.user.sub,
action,
resource,
resourceId: req.params.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
diff: { after: body },
}).catch(console.error); // non-blocking — don't fail request on audit error
}
return originalJson(body);
};
next();
};
}
When Juspay/Razorpay sends a webhook (e.g., "payment succeeded"), anyone could POST to our webhook endpoint with fake data. HMAC-SHA256 with a shared secret proves the request genuinely came from the payment gateway.
const crypto = require('crypto');
// Razorpay webhook verification
function verifyRazorpayWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // raw Buffer — must NOT parse JSON before this
.digest('hex');
// Timing-safe comparison prevents timing attacks
// Regular string comparison leaks timing info about how many chars match
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}
// Middleware usage
app.post('/webhooks/razorpay',
express.raw({ type: 'application/json' }), // raw body required for HMAC
(req, res, next) => {
const sig = req.headers['x-razorpay-signature'];
if (!verifyRazorpayWebhook(req.body, sig, process.env.RAZORPAY_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
req.body = JSON.parse(req.body); // parse after verification
next();
},
webhookController.handleRazorpay
);
Q: What is a timing attack and why does timingSafeEqual matter?
A: Regular string comparison (a === b) short-circuits as soon as characters differ — it takes slightly longer if more characters match. An attacker can measure response times to learn how many bytes of their forged HMAC match the real one, allowing gradual reconstruction. crypto.timingSafeEqual always compares all bytes in constant time regardless of where the difference is, leaking no timing information.
POST /auth/login: 5 requests/minute per IP — brute-force protectionPOST /auth/otp: 3 requests/minute per phone — OTP abuse preventionGET /api/*: 100 requests/minute per authenticated userPOST /webhooks/*: unlimited (gateways need reliability), but HMAC-verified{
"Name": "APIRateLimitRule",
"Priority": 1,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"AggregateKeyType": "IP",
"EvaluationWindowSec": 300,
"ScopeDownStatement": {
"ByteMatchStatement": {
"FieldToMatch": { "UriPath": {} },
"PositionalConstraint": "STARTS_WITH",
"SearchString": "/api/"
}
}
}
},
"Action": { "Block": {} }
}
This WAF rule blocks any IP making more than 100 requests in 5 minutes to /api/* — enforced at the CloudFront edge, before traffic reaches EC2. The NGINX limit_req_zone is the second layer for direct EC2 access.