05.12.2025

How I Found a Critical Auth Bypass in 2 Hours

Auth Bypass Case Study Pentest Web Security

The Setup

A B2B SaaS platform. Multi-tenant. React frontend, Node.js backend, JWT for authentication. I started the engagement at 9 AM. By 11 AM, I was browsing the admin panel as a superadmin.

No source code access. Pure black-box.

Step 1: Understand the Roles

The application had three visible user roles (from signup and API responses):

  • Viewer — read-only access to assigned projects
  • Member — can edit projects, invite users
  • Admin — manages the tenant: billing, users, settings
  • But the JWT payload told a different story:

    {
      "sub": "user_abc123",
      "tenant": "tenant_xyz",
      "role": "member",
      "iat": 1701432000,
      "exp": 1701518400
    }

    The role field was a string. What happens if we change it?

    Step 2: Test Role Escalation

    The JWT was signed with HS256. Without the secret, I couldn't forge tokens. But I could test what happens with a modified token (the server would reject it — or would it?).

    I intercepted a request to /api/admin/users and removed the Authorization header entirely.

    403 Forbidden. Expected.

    I sent it with a valid user JWT (role: member).

    200 OK. Full admin user list returned.

    The endpoint checked for any valid JWT — but not the role claim inside it. The only thing between a regular user and the admin API was the frontend hiding the admin navigation links.

    Step 3: Enumerate Every Admin Endpoint

    Once I knew the pattern, I checked common admin routes:

    GET  /api/admin/users          → 200 (all users across all tenants)
    GET  /api/admin/billing        → 200 (subscription data, revenue)
    POST /api/admin/users/invite   → 200 (invite new admin users)
    GET  /api/admin/settings       → 200 (SMTP config, API keys)
    POST /api/admin/settings       → 200 (could change SMTP to exfiltrate emails)

    Every single admin endpoint was protected by authentication (valid JWT required) but zero authorization (no role or permission check).

    The Root Cause

    The team had implemented JWT verification as a global middleware:

    // middleware/auth.js
    const jwt = require('jsonwebtoken');
    
    

    module.exports = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).send('Unauthorized');

    try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); // <-- Any valid token passes. No role check. } catch { res.status(401).send('Invalid token'); } };

    And the admin routes used the same middleware with no additional checks:

    // routes/admin.js
    router.use(authMiddleware); // Checks JWT exists & is valid
    router.get('/users', (req, res) => {
      // No check that req.user.role === 'admin'
      const users = await db.getAllUsers();
      res.json(users);
    });

    The Fix

    Two changes:

  • Role-based middleware — a second middleware that checks req.user.role against the required role:
  • const requireRole = (...roles) => (req, res, next) => {
      if (!roles.includes(req.user.role)) {
        return res.status(403).send('Forbidden');
      }
      next();
    };
    
    

    router.use(authMiddleware); router.use(requireRole('admin', 'superadmin')); router.get('/users', (req, res) => { ... });

  • Tenant-scoped queries — even admin users should only see their own tenant's data:
  • const users = await db.getUsersByTenant(req.user.tenant_id);

    Lessons

    • Don't trust the frontend to enforce authorization. Hiding admin links in the UI is not security. Attackers don't use your UI — they send requests directly.
    • Authorization is per-endpoint, not per-session. A valid session doesn't mean the user is allowed to access every endpoint.
    • Test with the lowest-privilege role first. I always start a black-box test as a "Viewer" — the weakest role. If I can escalate from there, you have a problem.
    ---

    This bug took 2 hours to find and document. It would have taken a real attacker about 10 minutes to exploit. The fix took the development team about 4 hours. The cost of not testing: a GDPR-reportable data breach across 50+ business customers.

    ← Back to Blog