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