Skip to content

Open redirect

When is it relevant

A new or changed code path that redirects to, or links to, a location taken from input. Examples are returnTo, next, redirect_uri, the Referer header, OAuth state, and client-side window.location assignments.

Attack

The attacker sends a link on your trusted domain that bounces the victim to a phishing site. Worse, a login or OAuth flow redirects to the attacker with codes or tokens attached. Prefix checks are bypassed with values like //evil.com, /\evil.com, https://app.example.com.evil.com and javascript:.

Mitigation

  • Redirect only to relative paths, validated by parsing them against your own origin, or to an allowlist of exact origins.
  • Compare parsed origins, never string prefixes.
  • For OAuth, match registered redirect URIs exactly.

Examples

Mitigation options

Option When it fits Docs
Allowlist of destinations Few known targets OWASP redirects
Accept a relative path only "Return to where you were" after login Express API
Parse and compare the host Redirects between your own domains Express API

Vulnerable — redirect to whatever arrives

router.get('/login', (req, res) => {
  if (req.session.userId) return res.redirect(req.query.next || '/dashboard')
  res.render('login', { next: req.query.next })
})

Vulnerable — "must start with a slash"

const next = req.query.next || '/'
if (!next.startsWith('/')) return res.redirect('/')
res.redirect(next)                     // //evil.com and /\evil.com still escape

//evil.com is a protocol-relative URL and /\evil.com is treated as one by several browsers — both start with /.

Mitigated — allowlist

const DESTINATIONS = { dashboard: '/dashboard', billing: '/billing', help: '/help' }
res.redirect(DESTINATIONS[req.query.next] ?? '/dashboard')

Mitigated — relative path only

function safeNext(value) {
  if (typeof value !== 'string') return '/dashboard'
  // exactly one leading slash, no backslashes, no scheme
  if (!/^\/(?!\/)[\w\-./?=&%]*$/.test(value)) return '/dashboard'
  return value
}

res.redirect(safeNext(req.query.next))

Mitigated — compare the resolved host

const ALLOWED_HOSTS = new Set(['app.example.com', 'docs.example.com'])

const target = new URL(req.query.next, 'https://app.example.com')
const ok = target.protocol === 'https:' && ALLOWED_HOSTS.has(target.host)
res.redirect(ok ? target.href : '/dashboard')

Resolving against your own base first means a relative path stays relative and an absolute URL is judged on its real host.

Docs: res.redirect · WHATWG URL

Bypasses to check

  • //evil.com, /\evil.com, \/\/evil.com, https:/\evil.com.
  • Userinfo tricks: https://app.example.com@evil.com/ — the host is evil.com; a startsWith('https://app.example.com') check passes.
  • Suffix matching: endsWith('example.com') allows evil-example.com and example.com.evil.com.
  • Check then change: the value is validated, then decoded (decodeURIComponent) or concatenated with a prefix before the redirect.
  • Double encoding (%252f%252f) and unicode look-alikes decoded downstream.
  • The redirect happening somewhere other than res.redirect: a Location header set by hand, an HTML meta refresh, or window.location in a template.
  • OAuth and SSO redirect_uri/state, logout returnTo, and any ?url=/?target=/?continue= parameter.
  • Open redirect used as a step in SSRF or to leak a token in the Referer.

What to grep for

  • res.redirect(, res.location(, Location, meta http-equiv="refresh"
  • req.query.next, returnTo, redirect_uri, continue, callback
  • startsWith('/'), endsWith(, includes(

Resources