Authentication & Authorization Guardian
Mission: Prevent authentication and authorization vulnerabilities through proactive security scanning and best practice enforcement. This skill operates in offensive mode - finding potential exploits and privilege escalation paths before attackers do.
Activation Triggers
- Implementing login/signup endpoints
- "Users can't stay logged in"
- JWT token configuration review
- "How do I protect admin routes?"
- Session persistence issues
- Password reset functionality
- Role-based access control (RBAC) implementation
- "Token expired" errors
- Production auth deployment prep
- OAuth/SSO integration
Tech Stack Awareness
This skill is specialized for PDFLab's auth stack:
- JWT tokens (access + refresh)
- Bcrypt password hashing
- Express middleware for route protection
- localStorage for token persistence (frontend)
- AuthContext (React Context API)
- Role-based access: User, Admin
- Plan-based limits: Free, Starter, Pro, Enterprise
Scan Methodology
1. Initial Context Gathering
Ask if not provided:
- "Show me your JWT middleware"
- "How are passwords hashed?"
- "Where are tokens stored?" (localStorage, cookies, memory)
- "What roles/permissions exist?"
- "Is there a refresh token mechanism?"
- "How is session restored on page reload?"
2. Critical Security Scan
Execute ALL checks in this section. Each is based on real security breaches.
🔴 CRITICAL: JWT Token Security
Historical Vulnerability: JWT secret leaked via .env in git, attackers forged admin tokens
Scan for:
- Strong JWT secret (32+ random characters)
- HS256 or RS256 algorithm (not 'none')
- Token expiration set (7d max for access tokens)
- Refresh token rotation
- Secret not in git (.env in .gitignore)
- Signature verification on every request
- Token payload doesn't contain sensitive data (passwords, credit cards)
Red flags:
// ❌ Weak secret
const JWT_SECRET = 'secret123' // Error! Easily guessed
// ❌ No expiration
jwt.sign({ userId }, JWT_SECRET) // Token valid forever!
// ❌ Algorithm 'none' (no signature)
jwt.sign({ userId }, '', { algorithm: 'none' }) // Anyone can forge
// ❌ Sensitive data in payload
jwt.sign({ userId, password: 'hunter2' }, JWT_SECRET) // Error! Payload is visible
// ❌ No signature verification
const decoded = jwt.decode(token) // Error! Doesn't verify signature
Hardening:
// ✅ Strong random secret (generate once, store in .env)
// Generate: openssl rand -base64 32
// .env
JWT_SECRET=Kx7jN9mP2qR4sT6vW8yZ0aB3cD5eF7gH9iJ1kL3mN5oP7qR9sT1uV3wX5yZ7aB9
// ✅ Proper token generation with expiration
import jwt from 'jsonwebtoken'
interface TokenPayload {
userId: string
email: string
plan: string
role: 'user' | 'admin'
}
export function generateAccessToken(user: User): string {
const payload: TokenPayload = {
userId: user.id,
email: user.email,
plan: user.plan,
role: user.role
}
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '7d', // Access token valid for 7 days
algorithm: 'HS256'
})
}
export function generateRefreshToken(user: User): string {
return jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET!, // Different secret
{ expiresIn: '30d' } // Refresh token valid for 30 days
)
}
// ✅ Signature verification middleware
export const authMiddleware = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
// 1. Extract token
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' })
}
const token = authHeader.substring(7)
// 2. Verify signature and expiration
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload
// 3. Load user from database (verify not deleted/banned)
const user = await User.findByPk(decoded.userId)
if (!user) {
return res.status(401).json({ error: 'User not found' })
}
// 4. Attach to request
req.user = user
next()
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' })
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' })
}
return res.status(500).json({ error: 'Authentication failed' })
}
}
// ✅ Refresh token endpoint
app.post('/api/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.body
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as { userId: string }
const user = await User.findByPk(decoded.userId)
if (!user) {
return res.status(401).json({ error: 'User not found' })
}
// Generate new access token
const newAccessToken = generateAccessToken(user)
// Optionally rotate refresh token
const newRefreshToken = generateRefreshToken(user)
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
})
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' })
}
})
Token expiration best practices:
- Access token: 15min - 7 days (PDFLab uses 7d for UX)
- Refresh token: 30-90 days
- Sensitive operations: Require fresh login (<5 min old token)
🔴 CRITICAL: Password Security
Historical Vulnerability: Plain text passwords in database, breach exposes all accounts
Scan for:
- Bcrypt hashing (not MD5, SHA1, or plain text)
- Salt rounds ≥ 10 (12 recommended)
- Password complexity requirements (8+ chars, mix of types)
- Password reuse prevention (check against history)
- Timing-safe password comparison
- Rate limiting on login endpoint (brute force prevention)
- Account lockout after N failed attempts
Red flags:
// ❌ Plain text password storage
await User.create({ email, password }) // Error! Plain text in DB
// ❌ Weak hashing
const hash = crypto.createHash('md5').update(password).digest('hex') // Error! MD5 is broken
// ❌ No salt (rainbow table attacks)
const hash = bcrypt.hashSync(password, 1) // Error! Salt rounds = 1 (too low)
// ❌ Timing attack vulnerability
if (user.password === password) { // Error! Leaks info via timing
// login
}
// ❌ No rate limiting
app.post('/login', loginHandler) // Can brute force 1000s of attempts
Hardening:
// ✅ Bcrypt with proper salt rounds
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12 // Good balance of security and performance
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash) // Timing-safe comparison
}
// ✅ Password complexity validation
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain lowercase letter')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain uppercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain number')
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('Password must contain special character (!@#$%^&*)')
}
return { valid: errors.length === 0, errors }
}
// ✅ Signup with password validation
app.post('/api/auth/register', async (req, res) => {
const { email, password, name } = req.body
// Validate password
const validation = validatePassword(password)
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors })
}
// Check if user exists
const existingUser = await User.findOne({ where: { email } })
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' })
}
// Hash password
const passwordHash = await hashPassword(password)
// Create user
const user = await User.create({
email,
password_hash: passwordHash,
name
})
res.status(201).json({ message: 'User created successfully' })
})
// ✅ Login with timing-safe comparison
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body
// Find user
const user = await User.findOne({ where: { email } })
if (!user) {
// Use bcrypt.compare even if user not found (prevent timing attack)
await bcrypt.compare(password, '$2b$12$dummyhashtopreventtimingattack')
return res.status(401).json({ error: 'Invalid credentials' })
}
// Verify password (timing-safe)
const isValid = await verifyPassword(password, user.password_hash)
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' })
}
// Generate tokens
const accessToken = generateAccessToken(user)
const refreshToken = generateRefreshToken(user)
res.json({ accessToken, refreshToken, user: user.toJSON() })
})
// ✅ Rate limiting on login
import rateLimit from 'express-rate-limit'
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 login attempts per 15 min
message: 'Too many login attempts, please try again later',
skipSuccessfulRequests: true // Only count failed attempts
})
app.post('/api/auth/login', loginLimiter, loginHandler)
// ✅ Account lockout after failed attempts
// Add to User model
interface User {
failed_login_attempts: number
locked_until: Date | null
}
async function incrementFailedAttempts(user: User): Promise<void> {
user.failed_login_attempts += 1
// Lock account after 5 failed attempts (30 min lockout)
if (user.failed_login_attempts >= 5) {
user.locked_until = new Date(Date.now() + 30 * 60 * 1000)
}
await user.save()
}
async function resetFailedAttempts(user: User): Promise<void> {
user.failed_login_attempts = 0
user.locked_until = null
await user.save()
}
// In login handler:
if (user.locked_until && user.locked_until > new Date()) {
const minutesLeft = Math.ceil((user.locked_until.getTime() - Date.now()) / 60000)
return res.status(429).json({
error: `Account locked. Try again in ${minutesLeft} minutes.`
})
}
🔴 CRITICAL: Authorization & RBAC
Historical Vulnerability: Regular users accessing admin panel via URL guessing
Scan for:
- Role-based middleware (admin vs user)
- Resource ownership verification (users can only access their own data)
- Plan-based feature gates (Free vs Pro features)
- Privilege escalation prevention (users can't promote themselves)
- Horizontal privilege escalation (user A can't access user B's data)
Red flags:
// ❌ No role check (anyone can delete users)
app.delete('/api/admin/users/:id', authMiddleware, deleteUser)
// ❌ No ownership check (can delete other users' jobs)
app.delete('/api/jobs/:id', authMiddleware, async (req, res) => {
await ConversionJob.destroy({ where: { id: req.params.id } })
// Didn't check if job belongs to req.user!
})
// ❌ Client-side role check only
// Frontend: if (user.role === 'admin') { showAdminPanel() }
// Backend has no check → can call admin API directly
// ❌ Users can change their own role
app.patch('/api/profile', authMiddleware, async (req, res) => {
const { role } = req.body // Error! User sets role = 'admin'
await req.user.update({ role })
})
Hardening:
// ✅ Role-based middleware
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' })
}
next()
}
// ✅ Plan-based middleware
export const requirePlan = (...allowedPlans: UserPlan[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !allowedPlans.includes(req.user.plan)) {
return res.status(403).json({
error: 'Upgrade required',
currentPlan: req.user?.plan,
requiredPlans: allowedPlans
})
}
next()
}
}
// Usage
app.delete('/api/admin/users/:id', authMiddleware, requireAdmin, deleteUser)
app.post('/api/batch/upload',
authMiddleware,
requirePlan(UserPlan.PRO, UserPlan.ENTERPRISE),
uploadBatchFiles
)
// ✅ Ownership verification
app.delete('/api/jobs/:id', authMiddleware, async (req, res) => {
const job = await ConversionJob.findByPk(req.params.id)
if (!job) {
return res.status(404).json({ error: 'Job not found' })
}
// Verify ownership (or admin override)
if (job.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Not your job' })
}
await job.destroy()
res.json({ message: 'Job deleted' })
})
// ✅ Protected fields (users can't change role/plan)
const PROTECTED_FIELDS = ['role', 'plan', 'conversions_limit', 'subscription_status']
app.patch('/api/profile', authMiddleware, async (req, res) => {
const updates = req.body
// Check for protected fields
for (const field of PROTECTED_FIELDS) {
if (field in updates) {
return res.status(403).json({ error: `Cannot modify ${field}` })
}
}
// Allow name, email updates only
await req.user.update(updates)
res.json({ user: req.user.toJSON() })
})
// ✅ Reusable ownership check helper
export async function verifyOwnership<T extends { user_id: string }>(
model: any,
id: string,
userId: string,
userRole: string
): Promise<T | null> {
const resource = await model.findByPk(id)
if (!resource) {
return null
}
// Admin bypass
if (userRole === 'admin') {
return resource
}
// Ownership check
if (resource.user_id !== userId) {
throw new Error('Forbidden: Not your resource')
}
return resource
}
// Usage
const job = await verifyOwnership<ConversionJob>(
ConversionJob,
req.params.id,
req.user.id,
req.user.role
)
🟡 HIGH: Frontend Token Storage
Historical Vulnerability: XSS attack steals JWT from localStorage
Scan for:
- Token storage location (localStorage vs httpOnly cookie)
- XSS prevention (Content Security Policy)
- Token exposed in URL (query params or hash)
- Token logged to console/analytics
- Session restoration on page reload
Red flags:
// ❌ Token in URL (shows in browser history, server logs)
window.location.href = `/dashboard?token=${token}`
// ❌ Token logged
console.log('Login successful:', { user, token }) // Error! Visible in console
// ❌ No CSP headers (XSS can steal from localStorage)
// No Content-Security-Policy header
// ❌ Token in localStorage without httpOnly protection
localStorage.setItem('token', token) // Accessible to XSS
Hardening:
// ✅ httpOnly cookie (best security, not accessible to JavaScript)
// Backend sets cookie
app.post('/api/auth/login', async (req, res) => {
const { accessToken, refreshToken } = await login(req.body)
// Set httpOnly cookie (XSS can't access)
res.cookie('accessToken', accessToken, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
})
res.json({ message: 'Logged in successfully' })
})
// Middleware reads from cookie
export const authMiddleware = (req, res, next) => {
const token = req.cookies.accessToken // From cookie, not header
// ... verify token
}
// ✅ Content Security Policy headers
import helmet from 'helmet'
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Minimize unsafe-inline
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.cloudconvert.com']
}
}
}))
// ✅ If using localStorage, sanitize all user input (XSS prevention)
// Frontend
import DOMPurify from 'dompurify'
function displayUserName(name: string) {
const clean = DOMPurify.sanitize(name)
return <div>{clean}</div>
}
// ✅ Session restoration without exposing token
// Frontend: AuthContext.tsx
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Restore session on mount (backend reads httpOnly cookie)
fetch('/api/auth/profile', { credentials: 'include' })
.then(res => res.json())
.then(data => setUser(data.user))
.catch(() => setUser(null))
.finally(() => setLoading(false))
}, [])
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
)
}
Storage comparison:
| Method | XSS Risk | CSRF Risk | Best For |
|---|---|---|---|
| localStorage | High (accessible to JS) | Low | SPAs with good XSS protection |
| httpOnly cookie | Low (not accessible to JS) | Medium (need sameSite) | Traditional web apps |
| Memory only | Low (lost on refresh) | Low | High security, poor UX |
PDFLab uses localStorage - ensure XSS protection via:
- Input sanitization (DOMPurify)
- CSP headers
- Avoid
dangerouslySetInnerHTML
🟠 MEDIUM: Password Reset Security
Historical Vulnerability: Password reset tokens valid forever, guessable tokens
Scan for:
- Reset token is random (UUID or crypto.randomBytes)
- Token expiration (15-60 minutes)
- Token single-use (invalidated after reset)
- Rate limiting on reset requests
- Email verification before sending reset link
Red flags:
// ❌ Guessable token
const resetToken = user.id // Error! Predictable
// ❌ Token never expires
await PasswordReset.create({ user_id, token }) // Valid forever
// ❌ Token reusable
// Can use same reset link multiple times
// ❌ No rate limiting
app.post('/forgot-password', sendResetEmail) // Can spam emails
Hardening:
// ✅ Secure password reset flow
import crypto from 'crypto'
// 1. Request reset
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body
const user = await User.findOne({ where: { email } })
if (!user) {
// Don't reveal if email exists
return res.json({ message: 'If email exists, reset link sent' })
}
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString('hex')
// Hash token before storing (in case DB leaks)
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex')
// Store with 15-minute expiration
await PasswordReset.create({
user_id: user.id,
token: hashedToken,
expires_at: new Date(Date.now() + 15 * 60 * 1000)
})
// Send email with reset link
const resetUrl = `https://pdflab.pro/reset-password?token=${resetToken}`
await sendEmail(user.email, 'Password Reset', `Reset your password: ${resetUrl}`)
res.json({ message: 'If email exists, reset link sent' })
})
// 2. Verify token and reset password
app.post('/api/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body
// Hash token to match DB
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex')
// Find reset record
const reset = await PasswordReset.findOne({
where: {
token: hashedToken,
expires_at: { [Op.gt]: new Date() } // Not expired
}
})
if (!reset) {
return res.status(400).json({ error: 'Invalid or expired token' })
}
// Validate new password
const validation = validatePassword(newPassword)
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors })
}
// Update password
const user = await User.findByPk(reset.user_id)
user.password_hash = await hashPassword(newPassword)
await user.save()
// Invalidate token (single-use)
await reset.destroy()
res.json({ message: 'Password reset successful' })
})
// ✅ Rate limiting
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 reset requests per hour
message: 'Too many reset requests, try again later'
})
app.post('/api/auth/forgot-password', resetLimiter, forgotPasswordHandler)
3. Production Readiness Checklist
Generate this checklist in the auto-scan report:
AUTHENTICATION SECURITY SCORE: X/10
✅ JWT secret: 32+ random characters (not in git)
✅ Password hashing: Bcrypt with 12 salt rounds
✅ Token expiration: 7 days (access), 30 days (refresh)
✅ Role-based access control (admin vs user)
✅ Ownership verification on protected resources
✅ Rate limiting: Login (5/15min), Password reset (3/hour)
⚠️ Token storage: localStorage (XSS risk, add CSP headers)
⚠️ Missing: Account lockout after failed attempts
❌ Critical: Admin routes not protected with requireAdmin
❌ Security: Users can modify their own plan field
RISK LEVEL: [LOW/MEDIUM/HIGH/CRITICAL]
BLOCKERS: X critical issues must be resolved
OPTIMIZATIONS: Y security wins available
Output Format: Auto-Scan Report
═══════════════════════════════════════════════
🛡️ AUTHENTICATION & AUTHORIZATION GUARDIAN - SCAN RESULTS
═══════════════════════════════════════════════
📊 SCAN SCOPE
• Auth method: JWT (access + refresh)
• Password hashing: Bcrypt (12 rounds)
• Token storage: localStorage (frontend)
• Roles: User, Admin
• Plans: Free, Starter, Pro, Enterprise
🚨 CRITICAL FINDINGS: [count]
⚠️ HIGH PRIORITY: [count]
💡 OPTIMIZATIONS: [count]
🔐 AUTHENTICATION AUDIT:
✅ JWT signature verification: HS256 algorithm
✅ Password complexity: 8+ chars, mixed case, numbers, special
✅ Timing-safe comparison: bcrypt.compare()
❌ Token expiration: No expiration set (valid forever)
⚠️ Rate limiting: Login endpoint limited (5/15min)
🔒 AUTHORIZATION AUDIT:
✅ Role-based middleware: requireAdmin implemented
✅ Ownership checks: Jobs, subscriptions verified
❌ Plan gating: Batch feature not protected
⚠️ Protected fields: Users can modify plan field
═══════════════════════════════════════════════
FINAL VERDICT
═══════════════════════════════════════════════
Production Ready: [YES/NO/BLOCKED]
Risk Level: [LOW/MEDIUM/HIGH/CRITICAL]
Estimated Fix Time: [X hours]
NEXT ACTIONS:
1. [Most critical fix]
2. [Second priority]
3. [Optional optimization]
═══════════════════════════════════════════════
Key Principles
- Defense in depth: JWT + password hash + rate limiting + RBAC
- Zero trust: Verify every request, never trust client
- Principle of least privilege: Users get minimum necessary permissions
- Secure defaults: Deny access unless explicitly allowed
- Audit trails: Log all auth events (login, failed attempts, role changes)
- Password security: Bcrypt ≥ 10 rounds, complexity requirements
Quick Reference: Common Fixes
# Generate strong JWT secret
openssl rand -base64 32
# Test JWT token
# Install: npm install -g jwt-cli
jwt decode YOUR_TOKEN_HERE
# Check bcrypt rounds (from hash)
# Hash format: $2b$12$... (12 = salt rounds)
# Test login rate limiting
# Attempt 6 logins in < 15 min, should block 6th
PDFLab-Specific Patterns
backend/src/middleware/auth.middleware.ts:
- Export
authMiddleware,requireAdmin,requirePlan - Verify JWT signature with jwt.verify()
- Load user from database on each request
- Handle TokenExpiredError gracefully
contexts/AuthContext.tsx:
- Store token in localStorage (add CSP headers)
- Restore session on mount via GET /api/auth/profile
- Provide login(), logout(), checkAuth() methods
- Handle token expiration (redirect to login)
backend/src/models/User.ts:
- Store password_hash (never plain text)
- Add failed_login_attempts, locked_until fields
- Implement getMaxFileSize(), getMaxBatchSize() for plan gating
- Add role enum: 'user' | 'admin'
Admin Routes Protection:
app.get('/api/admin/users', authMiddleware, requireAdmin, listUsers)
app.delete('/api/admin/users/:id', authMiddleware, requireAdmin, deleteUser)
app.get('/api/admin/stats', authMiddleware, requireAdmin, getStats)
Plan-Based Feature Gating:
app.post('/api/batch/upload',
authMiddleware,
requirePlan(UserPlan.PRO, UserPlan.ENTERPRISE),
uploadBatchFiles
)