← Todas las noticias
security3 min de lectura

The post-login redirect nobody checks is a phishing tool waiting to happen

Por Seaworthy · 1 de septiembre de 2026

Cars driving on a highway under a clear blue sky

Photo by viktor rejent on Unsplash

Log a user in, then send them back to whatever page they were trying to reach. It's a small feature that almost every app needs, and it almost always looks like this:

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body)
  res.cookie('session', user.sessionToken)
  res.redirect(req.query.next || '/dashboard')
})

req.query.next comes from the URL, which means it comes from whoever crafted the link the user clicked. Nothing here checks that it points somewhere on your own site.

A redirect the user trusts is worth more than a link they don't

https://yourapp.com/login?next=https://evil-lookalike.com is a link to your real domain. It'll show your real login page, over your real TLS certificate, and a user who's learned to check the address bar before typing a password will check it and see exactly what they expect. Then they log in successfully, on the genuine site, and get redirected somewhere else entirely, at which point the phishing page just has to look convincing for a few more seconds to capture whatever comes next, or exploit an OAuth flow that trusted your domain to only ever redirect within itself.

This is also a common building block in OAuth and SSO exploits. An identity provider that redirects back to a redirect_uri your app validated loosely can be tricked into sending an authorization code to an attacker-controlled host instead of your callback route.

Only allow relative paths

function safeNext(next) {
  if (typeof next !== 'string') return '/dashboard'
  if (!next.startsWith('/') || next.startsWith('//')) return '/dashboard'
  return next
}

app.post('/login', async (req, res) => {
  const user = await authenticate(req.body)
  res.cookie('session', user.sessionToken)
  res.redirect(safeNext(req.query.next))
})

The next.startsWith('//') check matters and gets missed a lot. //evil.com/path looks like a relative path at a glance, no scheme, no host, just a path starting with a slash, but browsers treat a leading double slash as protocol-relative and resolve it to https://evil.com/path. A check that only guards against http:// or https:// prefixes lets this straight through.

An allowlist works too, and is easier to reason about with subdomains

If your app legitimately redirects across subdomains, checking startsWith('/') isn't enough on its own. Parse the URL and compare the host explicitly:

function safeNext(next, host) {
  try {
    const url = new URL(next, `https://${host}`)
    if (url.host !== host) return '/dashboard'
    return url.pathname + url.search
  } catch {
    return '/dashboard'
  }
}

Resolving against a known base and checking the resulting host is more reliable than string matching on the raw input, because it forces the browser's own URL parsing rules to run before you compare, rather than trying to anticipate every string that a URL parser might later treat differently than your regex did.

The same bug hides behind other names

Post-login redirects are the most visible case, but returnUrl, redirectTo, callback, and continue query parameters show up in password reset flows, logout handlers, and unsubscribe links too, and each one is worth the same question: does this only ever point back into our own app?

Seaworthy flags redirects built from a request parameter with no check against the app's own host.

Este artículo fue generado por IA y resume fuentes públicamente disponibles.

De los creadores de Seaworthy

Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.

Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.

$ npx seaworthycode