June 4, 2025
Claude Security Development

Authentication and Authorization Review: A Practical Security Framework

Here's the uncomfortable truth: most authentication systems work until they don't. You'll have JWT tokens floating around, session cookies you forgot about, role-based access control (RBAC) that someone implemented three years ago and never documented, and somewhere in your codebase, probably right now, there's a /admin endpoint that's checking if (user.role === "admin") without verifying the user actually authenticated.

If you're building systems with Claude Code—whether that's agent orchestration, multi-tenant workflows, or API security—you need a framework for reviewing authentication and authorization flows before they become disasters. This article walks you through that framework. We'll cover what to look for, common pitfalls, and concrete patterns for verification. You'll leave this article with a checklist you can run through any system and catch the vulnerabilities that nobody else noticed.

The Hidden Cost of Auth Debt

Auth security isn't like other technical debt. You can ship with inconsistent error handling and fix it later. You can refactor a module gradually. But auth security is binary in ways that matter: either your system is secure, or it's a disaster. There's no middle ground where "mostly secure" is acceptable. And once you discover a vulnerability, the cost of fixing it is multiplied by the number of users it might have exposed.

What makes this worse is that auth vulnerabilities are usually not obvious. A missing permission check on one endpoint isn't dramatic. A token that doesn't properly validate claims is subtle. A CORS misconfiguration doesn't break anything visibly—users don't notice their browser blocked a request. So vulnerabilities hide. They live in production for weeks or months, unknown. The discovery moment is usually painful: either a customer reports unexpected access, an audit finds it, or (worst case) someone exploits it and you're scrambling to respond.

The organizations with the strongest security practices have institutionalized auth reviews. Not annual penetration tests. Not relying on security reviews before major features. Systematic, regular, deliberate checks. This might be quarterly audits where someone walks through the seven gates we cover below. It might be continuous integration tests that validate auth patterns. It might be a security checklist that's part of every code review. The mechanism doesn't matter as much as the consistency. Auth is never an afterthought because the organization has made it structural—it's part of the process, not a special case.

Table of Contents
  1. The Hidden Cost of Auth Debt
  2. The Problem: Why Auth Reviews Matter
  3. The Auth Review Checklist: Seven Critical Gates
  4. Gate 1: Authentication Mechanism Review
  5. Gate 2: JWT Implementation Deep Dive
  6. Gate 3: Session Management and Token Lifecycle
  7. Gate 4: Authorization Logic and RBAC Verification
  8. Gate 5: Cross-Origin and API Boundary Security
  9. Gate 6: Secrets and Credential Storage
  10. Gate 7: Audit Logging and Monitoring
  11. Building Your Auth Review Process
  12. Understanding the Psychological Barriers to Good Auth
  13. Real-World Application: Auditing a System
  14. The Evolution of Auth Systems: From Theory to Production
  15. Why Auth Reviews Matter More Than You Think
  16. Common Failures in Real Systems
  17. How to Implement Reviews That Stick
  18. The Hidden Dependencies in Your System
  19. Summary: Auth Review as a Practice

The Problem: Why Auth Reviews Matter

Authentication and authorization are the bouncers at the door of your system. Authentication answers "Are you who you claim to be?" Authorization answers "What are you allowed to do?" They're different problems, and conflating them is where most security failures start.

Consider this scenario:

  1. Your system authenticates a user via JWT token ✓
  2. The JWT validates correctly ✓
  3. The user's claims (roles, permissions) are extracted from the token ✓
  4. But here's the catch: nobody re-verified that those claims still apply

Maybe the user's role changed in your database five minutes ago. Maybe their subscription lapsed. Maybe they were marked as malicious by your fraud system. The token is still valid cryptographically, but it's stale semantically. And your system grants them access anyway.

This is why reviews matter. Without a systematic approach to checking your auth flows, you'll miss edge cases like:

  • Token expiration not being enforced (a user logs out but can still use their token for days)
  • Session invalidation not cascading to all services (user logs out on device A, still logged in on device B)
  • RBAC relationships drifting out of sync with your database (code says user is admin, database says they're not)
  • Cross-origin requests bypassing origin validation (requests from compromised domains get through)
  • Privilege escalation through role assumption without re-verification (user changes their own role)
  • Credential storage patterns that leak secrets into logs (your JWT signing key is in error messages)

Let's build the review framework.

The Auth Review Checklist: Seven Critical Gates

Gate 1: Authentication Mechanism Review

Before you even look at authorization, you need to verify how users prove their identity. This is your first line of defense.

What to check:

  1. Token Generation

    • Are tokens generated with sufficient entropy? (Random number generation matters)
    • Is the signing algorithm secure (HS256+, RS256+)? (Not MD5 or SHA1)
    • Are tokens digitally signed or just Base64-encoded? (Base64 is encoding, not security)
    • Is the signing key properly protected? (Not hardcoded, not in git)
  2. Token Validation

    • Is signature validation enforced on every protected endpoint?
    • Are you validating the token's algorithm claim (not just accepting any algorithm)?
    • Is token expiration (exp) checked? (And enforced immediately)
    • Are you rejecting expired tokens immediately? (Or allowing graceful degradation)
  3. Credential Handling

    • Are passwords hashed with a proper algorithm (bcrypt, scrypt, Argon2)? (Not MD5, not SHA256)
    • Is salt/iteration count appropriate? (Bcrypt with cost >= 12, scrypt with N >= 2^14)
    • Are credentials transmitted over HTTPS only? (Never HTTP)
    • Are credentials ever logged or cached insecurely? (Check your logs!)

Here's what a secure token validation flow looks like:

javascript
// ❌ WEAK: Doesn't validate signature or algorithm
const validateTokenWeak = (token) => {
  const decoded = jwt.decode(token); // No verification!
  return decoded.userId && decoded.exp > Date.now() / 1000;
};
 
// ✅ STRONG: Full verification with algorithm constraint
const validateTokenStrong = (token) => {
  try {
    const decoded = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ["RS256"], // Explicit: no algorithm confusion
      issuer: EXPECTED_ISSUER,
      audience: EXPECTED_AUDIENCE,
    });
 
    // Additional semantic checks
    if (decoded.exp < Math.floor(Date.now() / 1000)) {
      throw new Error("Token expired");
    }
 
    // Verify claims are still valid in your system
    const user = db.getUser(decoded.userId);
    if (!user || user.status === "suspended") {
      throw new Error("User no longer valid");
    }
 
    return decoded;
  } catch (error) {
    logger.warn(`Token validation failed: ${error.message}`);
    throw new Error("Unauthorized");
  }
};

Red flags:

  • jwt.decode() without jwt.verify() (no signature validation) — this is a common mistake
  • Accepting all algorithms (allows algorithm confusion attacks like CVE-2016-5431)
  • No expiration check (tokens live forever)
  • No re-verification of user status against the database (trusting stale data)
  • Using symmetric algorithms (HS256) when you should use asymmetric (RS256) for distributed systems

Gate 2: JWT Implementation Deep Dive

JWTs are everywhere. They're also misunderstood everywhere. Let's look at the specific pitfalls that keep security researchers employed.

The JWT structure (Header.Payload.Signature):

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTY0NjAwMDAwMH0.
[signature]

When you decode the payload (eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTY0NjAwMDAwMH0), you get:

json
{
  "userId": "42",
  "role": "admin",
  "exp": 1646000000
}

Here's what people get wrong:

  1. Trusting claims without re-verification

The JWT says "role": "admin". Does your system re-check the database? Or do you just trust the token? This is the #1 mistake we see in production.

javascript
// ❌ VULNERABLE: Trusting JWT claims without re-verification
const checkPermission = (token, action) => {
  const decoded = jwt.verify(token, KEY);
  return decoded.role === "admin"; // Just checking JWT!
};
 
// ✅ SECURE: Re-verify against database
const checkPermission = (token, action) => {
  const decoded = jwt.verify(token, KEY);
  const user = db.getUser(decoded.userId);
 
  // Role might have changed since token was issued
  if (user.role !== decoded.role) {
    logger.warn(`JWT role mismatch for user ${user.id}`);
    // Decide: reject? update? log?
  }
 
  return user.role === "admin" && user.status === "active";
};

Why does this matter? Because a user's role could have changed between when the token was issued and when the request arrives. If you're running a payment system and someone's subscription expires, you need to know about it immediately, not when their token expires in a week.

  1. Embedding sensitive data in JWT claims

JWTs are signed but not encrypted. Anyone with the token can read the claims. Don't put passwords, SSNs, credit card numbers, API keys, or any PII in claims. We've seen production systems with credit card data in JWTs. It's as bad as you think.

javascript
// ❌ VULNERABLE: Sensitive data in claims
const badToken = jwt.sign(
  {
    userId: 42,
    email: user.email,
    apiKey: user.apiKey, // EXPOSED to anyone with the token
    passwordHash: user.passwordHash, // EXPOSED
    ssn: user.ssn, // EXPOSED
  },
  SECRET,
);
 
// ✅ SECURE: Only metadata in claims
const goodToken = jwt.sign(
  {
    userId: 42,
    role: user.role,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
  },
  SECRET,
);

Remember: Base64 encoding is not encryption. Decoding a JWT payload is trivial (it's literally atob(payload)). If you need to store sensitive data in a token, use JWE (JSON Web Encryption), not JWT.

  1. Not rotating signing keys

If your signing key is compromised, every token ever signed with it becomes a security vulnerability. You need key rotation. And you need a graceful transition period where both old and new keys work.

javascript
// ✅ SECURE: Key rotation with versioning
const JWT_KEYS = {
  current: process.env.JWT_KEY_CURRENT,
  previous: process.env.JWT_KEY_PREVIOUS, // For validation only
  older: process.env.JWT_KEY_OLDER, // Optional: longer transition window
};
 
// When validating, try current key first, then previous, then older
const validateToken = (token) => {
  const keyOrder = [JWT_KEYS.current, JWT_KEYS.previous, JWT_KEYS.older].filter(
    Boolean,
  );
 
  for (const key of keyOrder) {
    try {
      return jwt.verify(token, key);
    } catch (error) {
      if (error.name === "TokenExpiredError") throw error;
      // Continue to next key
    }
  }
 
  throw new Error("Token validation failed with all keys");
};

JWT Review Checklist:

  • Signature validation happens on every protected endpoint
  • Algorithm is explicitly constrained (no "none" or algorithm confusion)
  • Expiration is checked and enforced
  • Claims are re-verified against the database for permission checks
  • No sensitive data in claims (no passwords, SSNs, credit cards, API keys)
  • Key rotation strategy is documented and tested
  • Revocation mechanism exists (blacklist or short TTL)
  • Token format is validated before parsing (not just try/catch)

Gate 3: Session Management and Token Lifecycle

Tokens are temporary credentials. But "temporary" is vague. Does your system actually invalidate them?

Critical questions:

  1. Token Lifetime: What's the TTL (time-to-live)?

    • Too long (24+ hours): Compromised tokens are dangerous for days
    • Too short (5 minutes): Users get logged out constantly, leading to refresh-token abuse and security theater
    • Sweet spot: 15 minutes to 1 hour for access tokens; refresh tokens 7-30 days
  2. Refresh Token Pattern: Do you use access + refresh tokens?

javascript
// ✅ SECURE: Access + Refresh Token Pattern
const login = async (username, password) => {
  // Validate credentials (bcrypt comparison)
  const user = await validateCredentials(username, password);
 
  // Issue short-lived access token
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: "15m" },
  );
 
  // Issue longer-lived refresh token (stored securely server-side)
  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    REFRESH_SECRET,
    { expiresIn: "7d" },
  );
 
  // Store refresh token with user metadata
  await db.storeRefreshToken(user.id, {
    token: hash(refreshToken),
    issuedAt: new Date(),
    expiresAt: addDays(new Date(), 7),
  });
 
  return {
    accessToken,
    refreshToken, // Send to client (httpOnly cookie preferred)
    expiresIn: 900, // seconds
  };
};

Why this pattern? Because access tokens are short-lived (15 mins), so if they're compromised, the window is narrow. Refresh tokens are longer-lived but stored server-side, so you can revoke them instantly. This is the gold standard in OAuth 2.0 for a reason.

  1. Token Revocation: What happens when a user logs out, changes their password, or is suspended?
javascript
// ❌ WEAK: No revocation mechanism
const logout = async (userId) => {
  // Token is still valid until expiration!
  // User can use it from other devices/tabs
  // This is a disaster for security
};
 
// ✅ STRONG: Token version-based revocation
const logout = async (userId) => {
  const user = await db.getUser(userId);
 
  // Increment token version (invalidates all tokens issued before this)
  await db.updateUser(userId, {
    tokenVersion: (user.tokenVersion || 0) + 1,
  });
};
 
// On every request, verify token version matches user's current version
const validateToken = (token) => {
  const decoded = jwt.verify(token, SECRET);
  const user = await db.getUser(decoded.userId);
 
  if (decoded.tokenVersion !== user.tokenVersion) {
    throw new Error('Token revoked');
  }
 
  return decoded;
};

The token version approach is elegant: you're not maintaining a blacklist (which doesn't scale), you're just checking if the token version matches the current user version. When a user logs out, you increment the version, and all existing tokens become invalid instantly.

  1. Session Invalidation Cascade: When a user's permission changes, does the system know?
javascript
// ✅ SECURE: Permission change invalidates tokens
const revokeUserAdminAccess = async (userId) => {
  // 1. Update user in database
  await db.updateUser(userId, { role: "user" });
 
  // 2. Invalidate existing tokens
  await db.updateUser(userId, {
    tokenVersion: (user.tokenVersion || 0) + 1,
  });
 
  // 3. If using refresh tokens with explicit tracking, delete them
  await db.deleteRefreshTokens(userId);
 
  // 4. Audit log
  logger.info(`Admin access revoked for user ${userId}`, {
    timestamp: new Date(),
    triggeredBy: currentUser.id,
    reason: "Manual revocation",
  });
 
  // 5. Notify user (optional but recommended)
  await sendNotification(userId, "Your admin access has been revoked");
};

Session Management Checklist:

  • Access token TTL is appropriate (15m - 1hr recommended)
  • Refresh token pattern is used (not just long-lived access tokens)
  • Logout actually invalidates tokens (version increments or blacklists)
  • Permission changes cascade to token invalidation
  • Refresh token storage is secure (hashed, server-side only)
  • Token version/chain is tracked in database
  • Cross-session invalidation works (one password change logs out all devices)
  • Refresh token endpoint validates user's current state

Gate 4: Authorization Logic and RBAC Verification

Authentication says "you are who you claim." Authorization says "here's what you can do." This is where the real damage happens when done wrong.

Role-Based Access Control (RBAC) is the most common pattern. But it's easy to get wrong.

Common problems:

  1. Inconsistent role checks
javascript
// ❌ INCONSISTENT: Role check on some endpoints, not others
app.get("/users", (req, res) => {
  // No auth check! Anyone can list users
  res.json(db.getAllUsers());
});
 
app.post("/users", requireRole("admin"), (req, res) => {
  // Auth check here
  res.json(db.createUser(req.body));
});
 
// ✅ CONSISTENT: Auth required everywhere
const protectedEndpoints = [
  "GET /users",
  "GET /users/:id",
  "POST /users",
  "PUT /users/:id",
  "DELETE /users/:id",
];
 
app.use((req, res, next) => {
  const route = `${req.method} ${req.baseUrl}${req.path}`;
  if (protectedEndpoints.some((p) => matchRoute(p, route))) {
    return requireAuth(req, res, next);
  }
  next();
});

The principle: deny by default. Only public endpoints should work without auth. Everything else should require explicit permission.

  1. Role assumption without ownership verification
javascript
// ❌ VULNERABLE: User can modify any user if they have role
app.put('/users/:id', requireRole('admin'), (req, res) => {
  const userId = req.params.id;
  await db.updateUser(userId, req.body); // No ownership check
});
 
// User A (admin) can now modify User B
// User A (admin) can even modify themselves to super-admin
// This is how privileges get escalated
 
// ✅ SECURE: Role + Ownership + Audit
app.put('/users/:id', requireAuth, async (req, res) => {
  const targetUserId = req.params.id;
  const currentUser = req.user;
 
  // Can only modify yourself OR you're a super-admin
  if (
    currentUser.id !== targetUserId &&
    currentUser.role !== 'super-admin'
  ) {
    return res.status(403).json({ error: 'Forbidden' });
  }
 
  // If modifying roles, require super-admin
  if (req.body.role && currentUser.role !== 'super-admin') {
    return res.status(403).json({ error: 'Cannot modify roles' });
  }
 
  // If modifying critical fields (like email), require MFA
  if (req.body.email && !req.user.mfaVerified) {
    return res.status(403).json({ error: 'MFA required for email changes' });
  }
 
  // Audit the change
  logger.info(`User ${currentUser.id} modified user ${targetUserId}`, {
    changes: req.body,
    timestamp: new Date(),
    ip: req.ip,
    userAgent: req.get('user-agent'),
  });
 
  await db.updateUser(targetUserId, req.body);
  res.json({ success: true });
});
  1. RBAC drift: Role definitions in code don't match the database
javascript
// ✅ SECURE: RBAC centralized and versioned
const ROLES = {
  user: {
    description: "Basic user",
    permissions: ["read:profile", "write:profile"],
  },
  moderator: {
    description: "Can moderate content",
    permissions: [
      "read:profile",
      "write:profile",
      "read:posts",
      "write:posts",
      "moderate:posts",
      "ban:users",
    ],
  },
  admin: {
    description: "Full system access",
    permissions: ["*"],
  },
};
 
// Store in database and version
const hasPermission = (user, requiredPermission) => {
  const role = ROLES[user.role];
  if (!role) throw new Error(`Unknown role: ${user.role}`);
 
  if (role.permissions.includes("*")) return true; // Wildcard
  return role.permissions.includes(requiredPermission);
};
 
// Verify role definitions are in sync
const validateRBACConsistency = async () => {
  const dbRoles = await db.getAllRoles();
  const codeRoles = Object.keys(ROLES);
 
  const missing = codeRoles.filter((r) => !dbRoles.includes(r));
  if (missing.length > 0) {
    throw new Error(`Roles defined in code but not DB: ${missing.join(", ")}`);
  }
};

RBAC Verification Checklist:

  • All protected endpoints require authentication
  • Role checks are centralized (not scattered throughout code)
  • Role definitions are versioned and auditable
  • Users cannot modify their own roles
  • Ownership checks prevent cross-user access
  • Role hierarchy is documented (admin > moderator > user)
  • Privilege escalation paths are blocked
  • Permission changes are logged with context

Gate 5: Cross-Origin and API Boundary Security

If your system serves requests from different origins (frontend, mobile app, third-party integrations), you need CORS and API security controls. This is where attackers probe for weaknesses across domain boundaries.

CORS (Cross-Origin Resource Sharing) misconfigurations:

javascript
// ❌ VULNERABLE: Overly permissive CORS
app.use(
  cors({
    origin: "*", // Anyone can make requests
    credentials: "include", // ... and include credentials
  }),
);
 
// ❌ VULNERABLE: Dynamic origin without validation
app.use(
  cors({
    origin: (origin, callback) => {
      // Trusts whatever origin is sent
      callback(null, true);
    },
    credentials: "include",
  }),
);
 
// ✅ SECURE: Explicit origin whitelist
const ALLOWED_ORIGINS = [
  "https://app.example.com",
  "https://admin.example.com",
  "https://api.example.com", // If SPA is on different subdomain
];
 
app.use(
  cors({
    origin: (origin, callback) => {
      if (!origin || ALLOWED_ORIGINS.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error("Not allowed by CORS"));
      }
    },
    credentials: "include",
    optionsSuccessStatus: 200,
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    maxAge: 86400, // 24 hours
  }),
);

API key handling:

javascript
// ❌ VULNERABLE: API key in URL or logs
const apiKey = req.query.apiKey; // Visible in browser history, logs, proxies
const apiKey = req.headers["api-key"]; // Better, but still loggable
 
// ✅ SECURE: Authorization header + secret rotation
app.use((req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing authorization" });
  }
 
  const token = authHeader.substring(7);
 
  // Validate and rotate if needed
  const apiKey = validateAndRotateIfNeeded(token);
  req.apiKey = apiKey;
  next();
});
 
// Rate limiting per API key
const apiKeyRateLimits = new Map();
app.use((req, res, next) => {
  const key = req.apiKey.id;
  const limit = 100; // requests per minute
  const now = Date.now();
 
  if (!apiKeyRateLimits.has(key)) {
    apiKeyRateLimits.set(key, []);
  }
 
  const requests = apiKeyRateLimits.get(key).filter((t) => now - t < 60000); // Last 60 seconds
 
  if (requests.length >= limit) {
    return res.status(429).json({ error: "Rate limit exceeded" });
  }
 
  requests.push(now);
  apiKeyRateLimits.set(key, requests);
  next();
});

API Boundary Checklist:

  • CORS origin is explicitly whitelisted (never use * with credentials)
  • API keys are in Authorization header (not URL parameters)
  • API keys are hashed and rotated regularly
  • Rate limiting is enforced per API key/user
  • Sensitive headers are stripped in responses
  • HTTPS is enforced (no HTTP fallback)
  • Error messages don't leak system information
  • OPTIONS requests are handled securely

Gate 6: Secrets and Credential Storage

Your authentication system is only as secure as how you store the keys that make it work. This is often the forgotten gate, and it's where the most damage happens.

Where secrets live (in order of security):

  1. Hardware Security Modules (HSMs) - Most secure, production-grade, expensive
  2. Secret management services (Vault, AWS Secrets Manager, Google Secret Manager) - Excellent, auditable, production standard
  3. Environment variables (in secure CI/CD) - Good, accessible to application, but not rotated
  4. Configuration files - Dangerous, easy to commit to git
  5. Environment files (.env) - Common but risky
  6. Hardcoded in source - Never, ever do this
javascript
// ❌ VULNERABLE: Hardcoded secrets
const JWT_SECRET = "my-super-secret-key-12345";
const DB_PASSWORD = "admin123";
 
// ❌ WEAK: .env file that might be committed
// .env (accidentally in git — don't do this)
JWT_SECRET = my - secret;
DB_PASSWORD = admin123;
 
// ✅ SECURE: Environment variables from secure source
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("JWT_SECRET environment variable not set");
}
 
// ✅ EXCELLENT: Secrets manager integration
const AWS = require("aws-sdk");
const secretsManager = new AWS.SecretsManager();
 
const getSecret = async (secretName) => {
  try {
    const data = await secretsManager
      .getSecretValue({ SecretId: secretName })
      .promise();
    return JSON.parse(data.SecretString);
  } catch (error) {
    logger.error(`Failed to retrieve secret: ${secretName}`, error);
    throw error;
  }
};
 
// On startup
const secrets = await getSecret("prod/jwt-keys");
const JWT_SECRET = secrets.key;
 
// ✅ EXCELLENT: Rotation strategy
const rotateSecrets = async () => {
  const oldSecret = JWT_SECRET;
  const newSecret = await generateNewSecret();
 
  // Update in secrets manager
  await secretsManager.updateSecret({
    SecretId: "prod/jwt-key",
    SecretString: newSecret,
  });
 
  // Update in application (next restart or graceful reload)
  JWT_SECRET = newSecret;
 
  // Log the rotation
  logger.info("JWT secret rotated", { timestamp: new Date() });
};

Secrets management checklist:

  • Secrets never in version control (check git history with git log -S "secret")
  • Secrets loaded from secure environment only
  • Secrets rotated on a schedule (monthly minimum)
  • Secret access is logged and audited
  • Backup/disaster recovery secrets exist
  • Secrets are never logged or exposed in error messages
  • Expired secrets are cleaned up
  • Access to secrets manager is restricted
  • Rotation process is automated

Gate 7: Audit Logging and Monitoring

You can have perfect authentication code, but if you don't know when it's being exploited, you've failed. Monitoring is where your security posture becomes real.

What to log:

javascript
// ✅ COMPLETE AUDIT LOGGING
const auditLog = {
  // Authentication events
  "auth.login.success": { userId, ipAddress, userAgent, timestamp },
  "auth.login.failure": { username, reason, ipAddress, timestamp },
  "auth.logout": { userId, timestamp },
 
  // Authorization events
  "auth.access_denied": { userId, resource, reason, timestamp },
  "auth.permission_check": { userId, permission, allowed, timestamp },
 
  // Token events
  "token.issued": { userId, expiresAt, type: "access|refresh", timestamp },
  "token.revoked": { userId, reason, timestamp },
  "token.validation_failed": { reason, ipAddress, timestamp },
 
  // Privilege changes
  "user.role_changed": { userId, oldRole, newRole, changedBy, timestamp },
  "user.suspended": { userId, reason, changedBy, timestamp },
  "user.credentials_reset": { userId, changedBy, timestamp },
 
  // Suspicious activity
  "suspicious.multiple_failed_logins": {
    username,
    attempts,
    timeWindow,
    timestamp,
  },
  "suspicious.token_reuse": { userId, ipAddress, timestamp },
  "suspicious.permission_escalation_attempt": { userId, targetRole, timestamp },
};
 
// Implementation
const logAuthEvent = (eventType, data) => {
  const entry = {
    eventType,
    ...data,
    timestamp: new Date().toISOString(),
    severity: getSeverity(eventType),
  };
 
  // Write to both local logs and SIEM/monitoring system
  logger.info(entry);
  monitoringService.send(entry);
};
 
// Monitoring and alerting
const detectSuspiciousActivity = async () => {
  // Alert on multiple failed logins
  const failedLogins = await auditLog
    .find({
      eventType: "auth.login.failure",
      timestamp: { $gte: new Date(Date.now() - 15 * 60 * 1000) }, // Last 15 min
    })
    .groupBy("username");
 
  for (const [username, attempts] of failedLogins) {
    if (attempts.length > 5) {
      alert(`Brute force attempt on ${username}: ${attempts.length} failures`);
      // Consider temporary lockout
    }
  }
 
  // Alert on privilege escalation
  const roleChanges = await auditLog.find({
    eventType: "user.role_changed",
    timestamp: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, // Last hour
  });
 
  for (const change of roleChanges) {
    if (change.newRole === "admin" && change.changedBy === change.userId) {
      alert(`Self-privilege escalation: ${change.userId} promoted themselves`);
      // Immediately revoke and investigate
    }
  }
};

Audit logging checklist:

  • All authentication events are logged
  • All authorization decisions are logged
  • Failed attempts are logged with reason
  • User context (IP, user agent) is captured
  • Suspicious patterns trigger alerts
  • Logs are immutable and retained
  • Access to logs is restricted and audited
  • Log entries cannot be tampered with post-hoc
  • Failed login attempts don't expose usernames to attackers

Building Your Auth Review Process

Here's a repeatable checklist for auditing authentication and authorization in any system:

yaml
Auth Security Review Checklist:
  Authentication:
    - [ ] Token generation uses strong algorithms
    - [ ] Token validation enforces signature verification
    - [ ] Algorithms are explicitly constrained
    - [ ] Expiration is checked on every request
    - [ ] Credentials are hashed properly (bcrypt/scrypt/Argon2)
    - [ ] No hardcoded secrets in code
    - [ ] Password reset tokens are short-lived
 
  JWT Implementation:
    - [ ] Claims are re-verified against database
    - [ ] No sensitive data in claims
    - [ ] Key rotation strategy exists
    - [ ] Algorithm confusion attacks are prevented
    - [ ] Token revocation works
    - [ ] Token format validation before parsing
 
  Session Management:
    - [ ] Access token TTL is 15m-1hr
    - [ ] Refresh token pattern is used
    - [ ] Logout invalidates tokens
    - [ ] Permission changes cascade
    - [ ] Cross-session invalidation works
    - [ ] Concurrent session limits enforced
 
  Authorization (RBAC):
    - [ ] All protected endpoints require auth
    - [ ] Role checks are centralized
    - [ ] Users cannot self-escalate privileges
    - [ ] Ownership is verified
    - [ ] Role definitions are versioned
 
  API Security:
    - [ ] CORS origin is whitelisted
    - [ ] API keys use Authorization header
    - [ ] Rate limiting is enforced
    - [ ] HTTPS is mandatory
    - [ ] Error messages don't leak info
 
  Secrets:
    - [ ] No secrets in version control
    - [ ] Secrets from secure source
    - [ ] Rotation schedule exists
    - [ ] Access is logged
    - [ ] Never logged in plaintext
 
  Audit & Monitoring:
    - [ ] All auth events are logged
    - [ ] Suspicious patterns trigger alerts
    - [ ] Logs are immutable
    - [ ] Log access is restricted
    - [ ] Incident response playbook exists

Understanding the Psychological Barriers to Good Auth

Here's something textbooks miss: the biggest threat to auth security isn't technical flaws, it's complacency. You implement auth correctly once, it works for months, and your brain stops treating it as a critical system. You start taking shortcuts. "Just this one endpoint doesn't need authentication because it's internal." Except internal access is more common than you think. Or "I'll add the permission check later when we refactor." Later never comes.

The organizations with the best auth security practices have a few things in common. They run reviews quarterly, not annually. They have on-call rotations that include auth components, so someone is thinking about it constantly. They have junior developers pair with auth experts so knowledge spreads. They've realized that auth isn't something you solve once—it's something you sustain continuously.

The review framework in this article is designed to break that complacency. By running through the seven gates systematically, you're forcing yourself to think critically about the system's actual behavior, not just its theoretical correctness. You're asking hard questions: "Do we actually re-verify user status on every request, or are we trusting a stale token?" The answers are often uncomfortable. That discomfort is productive.

Another aspect teams often overlook: the relationship between auth and the rest of your stack. Your password reset system uses time-limited tokens, which are auth mechanisms. Your API rate limiting checks auth, which implies your auth system needs to be fast. Your logging system shouldn't contain auth credentials, which means your auth code needs to be careful about what gets logged. Your CI/CD pipeline potentially has higher auth stakes than your application. These hidden dependencies are where vulnerabilities hide.

Many teams also operate under a false dichotomy: either you trust your systems completely or you lock everything down to paranoia. The truth is more nuanced. You trust in layers. You trust the application code but verify cryptographic signatures. You trust the network within your data center but encrypt across the internet. You trust your developers but audit their changes. Good auth design is about understanding where trust breaks down and compensating for it.

Real-World Application: Auditing a System

Let's walk through auditing a specific system. Imagine you have:

  • A Node.js API (/api)
  • A React frontend (app.example.com)
  • Mobile app with API key auth
  • Admin dashboard requiring two-factor auth

Quick audit (2-3 hours):

  1. API Authentication: Are tokens validated on every endpoint? → Write a test that calls each protected endpoint with an invalid token and verify it returns 401.

  2. JWT Claims: Do you re-verify user status? → Check the code: is db.getUser() called before executing sensitive operations, or just jwt.verify()?

  3. CORS: What origins are allowed? → Check your CORS configuration. If it includes * and credentials: true, that's a critical vulnerability.

  4. Session Invalidation: What happens on logout? → Test: log out a user, then use the token from another device. Does it still work? It shouldn't.

  5. Privilege Escalation: Can a user make themselves admin? → Check if role updates go through the same endpoint as other user updates. If so, vulnerability.

  6. Audit Logs: What happens when something goes wrong? → Trigger a failed login and check your logs. Is it recorded? With IP and user agent?

Each test takes 15-30 minutes. Together, they paint a clear picture of your security posture. And each test has a clear pass/fail criterion.

The Evolution of Auth Systems: From Theory to Production

Here's something security textbooks don't teach you: the moment you deploy authentication into production, reality starts diverging from theory. Your pristine token implementation suddenly needs to handle users who never log out properly. Your beautiful RBAC structure needs to accommodate contractors with temporary elevated access. Your carefully designed secrets rotation plan needs to survive a 2 AM emergency where a developer accidentally committed a key to git. Theory is useful, but production wisdom comes from living with these systems.

Why Auth Reviews Matter More Than You Think

An authentication vulnerability isn't like a regular bug. A regular bug might affect one user, one transaction, one feature. An auth vulnerability affects your entire security model. It's the difference between a window lock breaking (annoying) and your front door lock breaking (catastrophic). Because if authentication is compromised, everything downstream—all your authorization checks, all your audit logs, all your data protection—becomes suspect. An attacker with authentication can assume they own your system.

This is why reviews need to be systematic. You can't security-review auth by skimming code. You need to think like an attacker systematically exploring every boundary. Does the token validation actually run? Is it possible to bypass? Can you generate your own token? Can you modify a token? Can you copy someone else's token and use it? Can you make the system accept a token that should be expired? Can you make it accept a token that was issued to someone else?

Common Failures in Real Systems

Every security breach we see traces back to one of these patterns:

Pattern 1: Trusting Input Without Re-Verification - This is the most common. The code validates a token once, extracts claims (like role="admin"), and then uses those claims for every subsequent decision without re-checking the database. The user's role changes in the database, but they're still admin until the token expires. For payment systems, this is a disaster. User's subscription expires in your database but they can still use premium features for days.

Pattern 2: Ceremony Without Security - Systems that look secure on the surface but have subtle breaks. Like: requiring MFA for login (looks good) but not re-validating MFA when changing critical settings (subtle break). Or: hashing passwords with bcrypt (good) but logging the plaintext password when auth fails (break). Or: rotating secrets (good) but never testing that the rotation actually works (break).

Pattern 3: Assuming Framework Magic - Using a web framework's authentication middleware without understanding what it's actually doing. "Express handles auth, so it's secure, right?" Wrong. Express doesn't automatically validate tokens on every endpoint. You have to explicitly require it. Many systems have public endpoints mixed with private ones, and nobody noticed they're using the same route handler.

Pattern 4: Inconsistent Boundaries - Some endpoints require authentication, others don't. Some require specific roles, others just check "is user?". Some require MFA, others don't. The inconsistency is where attacks happen. An attacker finds the unprotected endpoint doing sensitive work and exploits it while everyone's attention is on the fortified endpoints.

How to Implement Reviews That Stick

The real challenge isn't doing one review. It's making reviews an ongoing practice that actually prevents problems. Here's how systems that get this right do it:

Quarterly deep dives are necessary but not sufficient. Every quarter, schedule 4-6 hours where you systematically walk through the auth checklist we covered. Run the tests. Document findings. Plan fixes. But don't treat it like a one-time security audit. Treat it like a strategic review of how your auth is holding up to real-world stress.

Continuous monitoring is your early warning system. Set up alerts for patterns that shouldn't happen: same user logging in from 10 different countries in 5 minutes (impossible, so either account compromise or token theft). Same user failing login 50 times in an hour (brute force). User escalating their own role without triggering MFA (privilege escalation). These patterns detected early can stop breaches before they spread.

Incident post-mortems are your curriculum. Every time something goes wrong with auth—even minor things—you learn something. Did someone accidentally deploy an old token signing key? Now you understand why key rotation matters. Did a contractor's account stay active after they left? Now you understand why deprovisioning playbooks matter. Write these lessons down. Make them part of your team's institutional knowledge.

Team training creates distributed knowledge. Don't let one person be the "auth expert." Everyone on the team should understand the basics: what tokens are, how they're validated, why revalidation matters, what secrets are and where they live, what audit logs tell you. When everyone understands, fewer assumptions slip through.

The Hidden Dependencies in Your System

Here's what most reviews miss: auth doesn't exist in isolation. It's connected to ten other systems, and weaknesses in those systems become auth vulnerabilities.

For example, password reset tokens are temporary credentials. If your password reset system has a flaw—like the token doesn't actually expire, or it's guessable—an attacker can reset other users' passwords and hijack their accounts. So reviewing auth means reviewing password reset too.

Another example: your API documentation publicly shows which endpoints exist. If you have an admin endpoint (/api/admin/users) that's supposed to be private but you accidentally didn't require authentication on it, someone will find it and exploit it. So reviewing auth means checking that every endpoint in your API docs is actually protected.

Another example: if your logging system stores tokens (which it shouldn't, but sometimes does), then your logging system becomes an auth vulnerability. An attacker who gets log access has token dump.

The point: think of auth as a perimeter, not a single system. Check the perimeter. Check the edges where auth connects to other systems. Check that your assumptions about other systems (like "the logging system only captures events, not sensitive data") are actually true.

Summary: Auth Review as a Practice

Authentication and authorization aren't one-time problems you solve and forget. They're ongoing practices:

  • Review quarterly (or after security incidents)
  • Rotate secrets regularly (monthly minimum, weekly for production)
  • Monitor logs continuously (alerts for suspicious patterns)
  • Test privilege boundaries (automated or manual)
  • Keep dependencies updated (JWT libraries, crypto, frameworks)
  • Document everything (who can do what, why)
  • Train your team (security is everyone's responsibility)

If you're using Claude Code for multi-agent systems, you need this same rigor around:

  • Agent authentication (how does Agent B verify Agent A is legitimate?)
  • Authorization (what resources can Agent A access?)
  • Cross-agent communication (is Agent A's result safe for Agent B to trust?)

The frameworks we've covered translate directly. A suspicious agent result should trigger the same audit trail as a suspicious API request. Your AI agents are part of your security perimeter, not outside it.


Building secure systems at scale requires thinking like an attacker and preparing for the moment your defenses fail. Start with these seven gates. Make them part of your development workflow, not a checkbox you mark at the end. Your users' security depends on it.

-iNet

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project