A penetration test on an auth system follows a predictable playbook. The same vulnerabilities appear repeatedly across codebases, regardless of how careful the team thought they were being. Knowing what testers look for — and checking for it yourself before the engagement — turns a painful audit finding into a routine fix. This post covers the six most commonly found auth vulnerabilities in SaaS products.
1. JWT "none" algorithm attack
JWTs can specify their signing algorithm in the header. In the early days of JWT libraries, some implementations would accept "alg": "none" as a valid algorithm, meaning no signature was required. An attacker could modify any JWT payload, set alg to "none", strip the signature, and be accepted as any user.
// VULNERABLE: trusting the algorithm from the token header
function verifyToken_INSECURE(token: string) {
// DO NOT DO THIS — lets attacker specify the algorithm
return jwt.verify(token, SECRET, { algorithms: undefined });
}
// CORRECT: always specify the expected algorithm explicitly
import { jwtVerify } from 'jose';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
async function verifyToken(token: string) {
// jwtVerify from 'jose' requires you to specify algorithm in the key,
// and will reject any token that doesn't match
const { payload } = await jwtVerify(token, SECRET, {
algorithms: ['HS256'], // explicit allowlist — never trust the header's 'alg'
});
return payload;
}
Modern JWT libraries default to rejecting none, but if you're on an older library or have written custom JWT verification, audit it explicitly. Test by sending a JWT with the header {"alg":"none","typ":"JWT"} and an empty signature segment.
2. Broken Object-Level Authorization (BOLA / IDOR)
BOLA is OWASP API Security Top 10 #1. The vulnerability: an API endpoint accepts a resource ID in the URL or body and returns the resource without verifying the caller owns it.
// VULNERABLE: no ownership check
router.get('/api/users/:userId/profile', requireAuth, async (req, res) => {
const profile = await db.users.findById(req.params.userId);
return res.json(profile); // anyone can read any user's profile
});
// CORRECT: verify the caller has access to the requested resource
router.get('/api/users/:userId/profile', requireAuth, async (req, res) => {
const requestedUserId = req.params.userId;
const callerUserId = req.auth.userId;
const callerOrgId = req.auth.orgId;
// Option 1: users can only read their own profile
if (requestedUserId !== callerUserId) {
// Unless they're an admin in the same org
const isAdmin = await db.orgMembers.isAdmin(callerUserId, callerOrgId);
const isInSameOrg = await db.orgMembers.isMember(requestedUserId, callerOrgId);
if (!isAdmin || !isInSameOrg) {
return res.status(403).json({ error: 'access_denied' });
}
}
const profile = await db.users.findById(requestedUserId);
return res.json(profile);
});
Pen testers look for this by creating two accounts (Account A and Account B), taking Account B's resource IDs, and accessing them while authenticated as Account A. If it returns data, it's vulnerable.
3. Mass assignment
Mass assignment occurs when you blindly spread user-provided body parameters onto a database object without filtering out sensitive fields. An attacker adds fields like role: 'admin' or emailVerified: true to a profile update request:
// VULNERABLE: spreading req.body directly into the update
router.patch('/api/users/:id', requireAuth, async (req, res) => {
const updated = await db.users.update(req.params.id, {
...req.body, // DANGEROUS: attacker can set role, emailVerified, planId, etc.
});
return res.json(updated);
});
// CORRECT: explicit allowlist of user-settable fields
const USER_UPDATABLE_FIELDS = ['name', 'avatarUrl', 'timezone', 'language'] as const;
type UserUpdatableField = typeof USER_UPDATABLE_FIELDS[number];
router.patch('/api/users/:id', requireAuth, async (req, res) => {
// Only pick the fields users are allowed to update
const updates: Partial<Record<UserUpdatableField, unknown>> = {};
for (const field of USER_UPDATABLE_FIELDS) {
if (field in req.body) updates[field] = req.body[field];
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'no_valid_fields' });
}
const updated = await db.users.update(req.params.id, updates);
return res.json(updated);
});
4. Token fixation on privilege escalation
Token fixation applies not just to session IDs (covered in the session management post) but also to any flow that upgrades privilege. A common case: an email verification or MFA flow that doesn't issue a new token/session after verification:
// VULNERABLE: same token before and after MFA verification
router.post('/auth/mfa/verify', async (req, res) => {
const { code } = req.body;
const session = await getSession(req.cookies.sid);
const valid = await verifyTotp(session.userId, code);
if (valid) {
// Just update the session record — VULNERABLE
await db.sessions.update(session.id, { mfaVerified: true });
return res.json({ success: true });
}
});
// CORRECT: issue a new session token after MFA is verified
router.post('/auth/mfa/verify', async (req, res) => {
const { code } = req.body;
const oldSession = await getSession(req.cookies.sid);
const valid = await verifyTotp(oldSession.userId, code);
if (valid) {
// Revoke the pre-MFA session
await db.sessions.revoke(oldSession.id);
// Issue a fresh session with mfaVerified = true
const newSessionId = await createSession(oldSession.userId, req, { mfaVerified: true });
res.cookie('sid', newSessionId, { httpOnly: true, secure: true, sameSite: 'strict' });
return res.json({ success: true });
}
});
5. Race conditions on single-use tokens
Email verification tokens, password reset tokens, and magic links are supposed to be single-use. If the consumption isn't atomic, two concurrent requests can both consume the same token successfully. Pen testers use Burp Suite's parallel request feature to fire two identical requests simultaneously:
-- Non-atomic check-then-update: race condition window
-- SELECT WHERE used_at IS NULL → both see NULL
-- UPDATE SET used_at = NOW() → both succeed
-- ATOMIC single-use consumption: use FOR UPDATE SKIP LOCKED or a CAS update
-- Only one concurrent update will succeed; the other gets zero rows affected
UPDATE verification_tokens
SET used_at = NOW()
WHERE token_hash = $1
AND used_at IS NULL -- check and update atomically
AND expires_at > NOW()
RETURNING user_id;
// In TypeScript: check rows affected
const result = await db.query(`
UPDATE verification_tokens
SET used_at = NOW()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > NOW()
RETURNING user_id
`, [tokenHash]);
if (result.rowCount === 0) {
// Token already used or expired — the concurrent request won
return res.status(400).json({ error: 'token_already_used' });
}
6. Insecure direct object reference on admin endpoints
Admin endpoints often have weaker authorization checks than user-facing endpoints, on the assumption that only admins can reach them. Pen testers look for admin endpoints discoverable through JS source maps, response headers, or API documentation, then test them with non-admin tokens:
// VULNERABLE: middleware checks auth but not admin role
router.get('/api/admin/users', requireAuth, async (req, res) => {
// Any authenticated user can access this
const users = await db.users.findAll();
return res.json(users);
});
// CORRECT: explicit role check
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.auth?.roles?.includes(role)) {
return res.status(403).json({ error: 'insufficient_permissions' });
}
next();
};
}
router.get('/api/admin/users',
requireAuth,
requireRole('admin'), // separate, explicit role check
async (req, res) => {
const users = await db.users.findAll();
return res.json(users);
}
);
Before your next pen test: the auth checklist
Run through these checks yourself before bringing in external testers:
- Send a JWT with
"alg":"none"to every auth-protected endpoint — expect 401 - Create two accounts, swap resource IDs between them — expect 403 on the other's resources
- Add
role: "admin"to any profile update body — expect it to be silently ignored - Fire two simultaneous requests with the same verification/reset token — expect exactly one success
- Use the pre-MFA session ID after completing MFA — expect 401
- Access admin endpoints with a regular user token — expect 403
These six tests take 30 minutes to run manually and will surface the most commonly found auth vulnerabilities before a formal engagement. A clean result on these basics means the external pen testers can spend their time finding harder, more interesting issues rather than low-hanging fruit.