Skip to content

Broken authentication

When is it relevant

A new or changed entry point (route, socket event, webhook, job trigger) that should only be reachable by a known caller. Also a change to how callers are identified: login, sessions, tokens, API keys or SSO.

Attack

The attacker calls the entry point with no credentials, or with expired, forged or wrongly scoped ones, and the server still treats them as an authenticated user. Common causes:

  • A route is mounted outside the authentication middleware.
  • Middleware fails open on errors or missing headers.
  • Tokens are decoded without being verified.
  • Credentials from one identity system are accepted as another.

Mitigation

  • Apply authentication at the router level so new routes are protected by default, and list public routes explicitly.
  • Fail closed: missing, malformed or unverifiable credentials end the request with 401.
  • Verify tokens fully: signature with a pinned algorithm, expiry, issuer and audience. Check that the session or key is still active.

Examples

Mitigation options

Option When it fits Docs
express-session + a server-side store Cookie-based web apps; lets you revoke a session server-side express-session
JWT (jsonwebtoken) Stateless APIs and service-to-service calls; revocation needs extra work node-jsonwebtoken
Passport strategies Delegated login (OIDC, SAML, social) on top of either of the above Passport sessions

Vulnerable — session that survives login

app.use(session({
  secret: 'dev-secret',            // hardcoded, shared across environments
  resave: false,
  saveUninitialized: true,         // issues a cookie to anonymous visitors
  cookie: { maxAge: 86400000 },    // no httpOnly, no secure, no sameSite
}))

app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email })
  if (!user || user.password !== hash(req.body.password)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }
  req.session.userId = user._id    // same session ID as before login
  res.json({ ok: true })
})

The session ID is never rotated, so an ID an attacker planted in the victim's browser before login becomes an authenticated session (session fixation).

Vulnerable — JWT that is read but not verified

const payload = jwt.decode(req.headers.authorization.slice(7))
req.user = payload                 // no signature check at all

Mitigated — express-session

app.set('trust proxy', 1)
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({ mongoUrl: process.env.MONGO_URL }),
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600000 },
}))

app.post('/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email })
  const ok = user && await bcrypt.compare(req.body.password, user.passwordHash)
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' })

  req.session.regenerate((err) => {          // new session ID after login
    if (err) return res.status(500).end()
    req.session.userId = user.id
    req.session.save(() => res.json({ ok: true }))
  })
})

app.post('/logout', (req, res) => {
  req.session.destroy(() => res.clearCookie('connect.sid').json({ ok: true }))
})

bcrypt.compare is constant-time, and the same 401 is returned whether the email exists or not, so the response can't be used to enumerate accounts.

Docs: express-session · session.regenerate · connect-mongo · bcrypt

Mitigated — JWT

function requireAuth(req, res, next) {
  const [scheme, token] = (req.headers.authorization || '').split(' ')
  if (scheme !== 'Bearer' || !token) return res.sendStatus(401)
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],       // pin it: never trust the header's alg
      issuer: 'api.example.com',
      audience: 'example-web',
      maxAge: '1h',
    })
    next()
  } catch {
    return res.sendStatus(401)
  }
}

Docs: jsonwebtoken

Bypasses to check

  • jwt.decode() used anywhere in the auth path — it parses without verifying.
  • jwt.verify() without algorithms — an attacker may switch alg to none or to HS256 signed with a public RSA key the server trusts.
  • No req.session.regenerate() on login (fixation), or logout that only clears req.session.userId without destroy(), leaving the old ID valid.
  • Identity taken from a client-supplied field (req.body.userId, X-User-Id) rather than from the verified session or token.
  • Password compared with === against a hash, or with a non-constant-time helper; different responses or timings for "no such user" vs "wrong password".
  • saveUninitialized: true plus a login that writes into the pre-existing session object.
  • Rate limiting applied only to /login while /register, password reset or token refresh reach the same credential check.

What to grep for

  • jwt.decode(, jwt.verify(, algorithms, ignoreExpiration
  • session(, saveUninitialized, regenerate(, destroy(, cookie:
  • bcrypt.compare, createHash(, === user.password

Resources