JWT + Refresh Token Rotation

Token lifecycle

Auth flow: Client Server Redis │ │ │ │── POST /auth/login ───────▶│ │ │ │── validate credentials │ │ │── issue tokens: │ │ │ access (15min TTL) │ │ │ refresh (30day TTL) │ │◀── { accessToken, │ │ │ refreshToken } ──────│── STORE refresh token ───▶│ │ │ │ │── API call with │ │ │ Authorization: Bearer │ │ │ <accessToken> ─────────▶│ │ │ │── verify JWT signature │ │◀── 200 response ──────────│ (no DB hit) │ │ │ │ │ [15 minutes later] │ │ │── POST /auth/refresh ─────▶│ │ │ { refreshToken } ───────│── GET refresh token ─────▶│ │ │◀── verify valid ──────────│ │ │── DEL old refresh ───────▶│ (rotation) │ │── issue new pair: │ │ │ new access (15min) │ │ │ new refresh (30day) │ │ │── STORE new refresh ─────▶│ │◀── { new tokens } ────────│ │

Why short-lived access tokens (15 min)?

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
}
Interview Q&A

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.

Audit Logging

Audit log schema

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();
  };
}

Webhook HMAC Verification

Why HMAC for webhooks?

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
);
Interview Q&A

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.

Token-Bucket Rate Limiting (Security Context)

Per-route limits

WAF custom rule (API abuse)

{
  "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.