DEV Community

vapmail16
vapmail16

Posted on

Authentication That Actually Passes Security Audits

I ran a security self-audit on my own auth code. Found 3 issues in the first hour.

Token expiry was too generous. Sessions weren't tracked by device. And password reset tokens weren't being invalidated after use. None of these would show up in a typical tutorial. All three would show up in an actual security review.

Most auth tutorials teach you the basics: hash the password, sign a JWT, protect a route. That gets you through the demo. Auditors check for depth — token rotation, MFA implementation, session tracking, role hierarchies, rate limiting. The gap between "works in development" and "passes a security questionnaire" is bigger than most developers expect.

Here's what a production auth system actually looks like, with real code.


1. JWT + Refresh Tokens: The Cookie-Based Approach

The first thing any auditor checks: where are you storing tokens?

If the answer is localStorage, you've already failed. Any XSS vulnerability — and every non-trivial app will eventually have one — gives an attacker full access to the token. Game over.

HTTP-only cookies fix this. The browser sends them automatically; JavaScript can't read them. Here's the login flow:

// Access token: short-lived (15 min), signed with a dedicated secret
const accessToken = jwt.sign(
  { userId },
  config.jwt.secret,
  { expiresIn: '15m' }
);

// Refresh token: long-lived (30 days), separate secret, stored in DB
const refreshToken = jwt.sign(
  { userId },
  config.jwt.refreshSecret,
  { expiresIn: '30d' }
);
Enter fullscreen mode Exit fullscreen mode

Both tokens go into HTTP-only cookies — never in the response body:

// Set access token — httpOnly prevents XSS from reading it
res.cookie('accessToken', accessToken, {
  httpOnly: true,
  secure: true,        // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 15 * 60 * 1000,
});

// Set refresh token — same protections, longer lifespan
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 30 * 24 * 60 * 60 * 1000,
});

// Body contains user info only — no tokens exposed
res.json({ success: true, data: loginResult.user });
Enter fullscreen mode Exit fullscreen mode

The refresh token is stored in the database tied to the session. When it's used, the old session is deleted and a new one is created. If a token is reused (i.e., someone stole it and the real user already rotated it), we know the session was compromised.

// Refresh: verify token, check DB session, issue new access token
const session = await prisma.session.findUnique({
  where: { token: refreshToken },
  include: { user: true },
});

if (!session || session.expiresAt < new Date()) {
  throw new UnauthorizedError('Invalid or expired refresh token');
}

if (!session.user.isActive) {
  throw new UnauthorizedError('Account is disabled');
}

const newAccessToken = jwt.sign(
  { userId: session.userId },
  config.jwt.secret,
  { expiresIn: '15m' }
);
Enter fullscreen mode Exit fullscreen mode

The middleware that protects routes tries the cookie first, falls back to the Authorization header for API clients:

export const authenticate = async (req, res, next) => {
  // Prefer cookie (browser), fall back to header (API/mobile)
  let token = req.cookies?.accessToken;

  if (!token) {
    const authHeader = req.headers.authorization;
    if (authHeader?.startsWith('Bearer ')) {
      token = authHeader.substring(7);
    }
  }

  if (!token) throw new UnauthorizedError('No token provided');

  const decoded = jwt.verify(token, config.jwt.secret);

  const user = await prisma.user.findUnique({
    where: { id: decoded.userId },
    select: { id: true, email: true, role: true, isActive: true },
  });

  if (!user || !user.isActive) {
    throw new UnauthorizedError('User not found or disabled');
  }

  req.user = user;
  next();
};
Enter fullscreen mode Exit fullscreen mode

2. MFA: Why SMS Fails Audits

SMS-based MFA is convenient and insecure. SIM swapping, SS7 vulnerabilities, social engineering at carrier stores — there's a reason NIST downgraded SMS verification years ago. Auditors know this.

TOTP (Google Authenticator, Authy) is the standard. Here's the setup flow — generate a secret, create a QR code, and store backup codes:

export const setupTotp = async (userId: string) => {
  const user = await prisma.user.findUnique({ where: { id: userId } });

  const secret = speakeasy.generateSecret({
    name: `MyApp (${user.email})`,
    length: 32,
  });

  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url, {
    width: 512,
    errorCorrectionLevel: 'M',
  });

  // Generate 10 backup codes (stored hashed in production)
  const backupCodes = await generateBackupCodes(userId);

  // Store secret — not enabled until user verifies first code
  await prisma.mfaMethod.upsert({
    where: { userId_method: { userId, method: 'TOTP' } },
    create: { userId, method: 'TOTP', secret: secret.base32, isEnabled: false },
    update: { secret: secret.base32, isEnabled: false },
  });

  return { secret: secret.base32, qrCodeUrl, backupCodes };
};
Enter fullscreen mode Exit fullscreen mode

The Prisma schema behind this:

model MfaMethod {
  id        String        @id @default(uuid())
  userId    String
  method    MfaMethodType // TOTP or EMAIL
  secret    String?
  isEnabled Boolean       @default(false)
  isPrimary Boolean       @default(false)
  createdAt DateTime      @default(now())
  updatedAt DateTime      @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@unique([userId, method])
}

model MfaBackupCode {
  id        String    @id @default(uuid())
  userId    String
  code      String    @unique
  used      Boolean   @default(false)
  usedAt    DateTime?
  createdAt DateTime  @default(now())
}
Enter fullscreen mode Exit fullscreen mode

MFA isn't enabled until the user verifies their first code. This prevents locking someone out if setup was interrupted. And Email OTP exists as a fallback for accessibility — not everyone has a smartphone.

During login, if MFA is enabled, we return a temporary token instead of the real session. The user completes MFA, and only then do we issue access/refresh tokens:

if (enabledMfaMethods.length > 0) {
  return {
    user: { id: user.id, email: user.email },
    requiresMfa: true,
    mfaMethod: primaryMethod.method,
    // No tokens — need MFA verification first
  };
}
Enter fullscreen mode Exit fullscreen mode

3. OAuth Edge Cases Nobody Warns You About

OAuth tutorials show "click Google, get user." Real implementations deal with:

  • Account linking: User signs up with email/password, later clicks "Connect with Google." You need to match the email and link providers, not create a duplicate.
  • Email conflicts: User has a Google account with user@gmail.com and tries to link GitHub which has a different email. Which one wins?
  • Multi-provider: Supporting Google, GitHub, and Microsoft simultaneously means three different token formats and profile shapes.

The link/unlink flow needs its own endpoints and audit logging:

router.post('/oauth/link', authenticate, async (req, res) => {
  const { provider, token } = req.body;

  const profile = await verifyOAuthToken(provider, token);
  const user = await linkOAuthToUser(req.user.id, provider, profile);

  await prisma.auditLog.create({
    data: {
      userId: req.user.id,
      action: 'OAUTH_LINKED',
      resource: 'users',
      details: { provider },
      ipAddress: getClientIp(req),
    },
  });

  res.json({ success: true, data: user });
});
Enter fullscreen mode Exit fullscreen mode

Every OAuth operation is audit-logged. When something goes wrong (and it will), you need to know exactly what happened and when.


4. Session Management

Sessions are tracked with device info. This isn't just for the "active sessions" UI — it's a security requirement:

model Session {
  id        String   @id @default(uuid())
  userId    String
  token     String   @unique
  expiresAt DateTime
  userAgent String?
  ipAddress String?
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Enter fullscreen mode Exit fullscreen mode

On login, existing sessions for that user are cleared. On password change, all sessions are revoked. This means: compromised password → change password → attacker is immediately locked out, even if they have a valid refresh token.


5. RBAC: Role Hierarchy With One Middleware

Three roles — USER, ADMIN, SUPER_ADMIN — with inheritance. The middleware is simple because the hierarchy is explicit:

export const requireRole = (...roles: string[]) => {
  return (req, res, next) => {
    if (!req.user) {
      return next(new UnauthorizedError('Authentication required'));
    }
    if (!roles.includes(req.user.role)) {
      return next(new ForbiddenError('Insufficient permissions'));
    }
    next();
  };
};

// Usage
router.get('/admin/users', authenticate, requireRole('ADMIN', 'SUPER_ADMIN'), handler);
router.delete('/admin/users/:id', authenticate, requireRole('SUPER_ADMIN'), handler);
Enter fullscreen mode Exit fullscreen mode

Every role change is audit-logged. An admin promoting someone to SUPER_ADMIN is a high-severity event that should be visible in your audit trail.


Auth Audit Readiness Checklist

Run this against your auth. Score yourself honestly.

# Check What Auditors Look For
1 Token storage HTTP-only cookies, not localStorage
2 Access token expiry 15 minutes or less
3 Refresh token rotation Old token invalidated on use
4 MFA support TOTP (not just SMS)
5 Backup codes Generated and stored for account recovery
6 Rate limiting Login, registration, password reset endpoints
7 Session tracking IP, user agent, device logged per session
8 Password change revocation All other sessions killed on password change
9 OAuth audit trail Link/unlink events logged with IP and timestamp
10 Role change logging Every privilege escalation tracked in audit log

If your auth tutorial doesn't cover token rotation and reuse detection, it's teaching you to build a vulnerability.


Run this checklist against your auth. What did you score?

Top comments (0)