v0.14 is out: a mobile auth pack for Swift/iOS and Android, catching insecure token storage, cleartext traffic, and OAuth in embedded WebViews. Read more →
HIGH AI PREVALENCE: HIGH auth.flow.open-redirect

Untrusted request input flows into a redirect destination.

CWE-601 OWASP A01:2021 js · ts DATAFLOW

Why AI tools produce this: AI coding tools generate this anti-pattern by default, it appears in a large share of AI-written auth code.

Dataflow rule. This is a taint-mode rule: it traces untrusted request input (query, body, params) through your code to an HTTP redirect destination, so indirection across multiple lines is caught, not just the direct one-line form. Routing the value through a recognised validation / allow-list sanitizer clears the taint and suppresses the finding. Why dataflow →

Why this matters

Because the target URL is attacker-controlled, this is an open redirect (CWE-601): an attacker can craft a link to your trusted host that bounces the victim to an arbitrary external site. That fuels phishing, and in OAuth flows it can be chained to steal authorization codes or access tokens by sending the victim (and their callback) to a server you do not control.

Never redirect to a raw req.query / req.body / req.params / req.cookies / req.headers value. Validate the destination against an explicit allow-list of hosts or route names, or only allow relative paths you control (reject anything containing a scheme or //).

VULNERABLE
vulnerable.ts
import type { Request, Response } from 'express';

// Direct: ?next= straight into res.redirect.
export function loginCallback(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.redirect(req.query.next as string);
}

// Direct with a status code argument.
export function returnTo(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.redirect(302, req.query.returnTo as string);
}

// Intra-procedural dataflow: assign to a local, then redirect.
export function afterPasswordReset(req: Request, res: Response): void {
  const dest = req.body.url as string;
  // ruleid: auth.flow.open-redirect
  res.redirect(dest);
}

// Route param flows into res.location().
export function gotoTenant(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.location(req.params.target);
}

// Header set explicitly to a request-controlled value.
export function legacyRedirect(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.set('Location', req.query.url as string);
}

// setHeader form.
export function rawRedirect(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.setHeader('Location', req.cookies.last_page);
}

// writeHead with a Location header built from a request header.
export function proxyHop(req: Request, res: Response): void {
  // ruleid: auth.flow.open-redirect
  res.writeHead(302, { 'Content-Type': 'text/plain', Location: req.headers.referer as string });
  res.end();
}
SAFE
safe.ts
import type { Request, Response } from 'express';

// Constant destination — never tainted, never fires.
export function dashboard(_req: Request, res: Response): void {
  res.redirect('/dashboard');
}

// Constant with status code.
export function home(_req: Request, res: Response): void {
  res.redirect(301, '/');
}

const ALLOWED_RETURNS = ['/account', '/billing', '/settings'];

// Allow-list validation helper returns a vetted path; the raw request input
// only reaches the sink after passing through the allow-list check.
function validateRedirect(candidate: string): string {
  return ALLOWED_RETURNS.includes(candidate) ? candidate : '/account';
}

export function safeReturn(req: Request, res: Response): void {
  const dest = validateRedirect(req.query.returnTo as string);
  res.redirect(dest);
}

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

// Host allow-list helper: only returns the URL when its host is trusted.
function isAllowedUrl(url: string): string {
  try {
    return ALLOWED_HOSTS.has(new URL(url).host) ? url : '/';
  } catch {
    return '/';
  }
}

export function validatedRedirect(req: Request, res: Response): void {
  const target = isAllowedUrl(req.body.url as string);
  res.redirect(target);
}

const ROUTES = new Set(['/inbox', '/profile']);

// Set-based allow-list helper.
function sanitizeRedirect(candidate: string): string {
  return ROUTES.has(candidate) ? candidate : '/inbox';
}

export function gatedLocation(req: Request, res: Response): void {
  const to = sanitizeRedirect(req.params.to);
  res.location(to);
}

const ALLOWED = new Set(['/account', '/billing']);

// Inline allow-list guard: the raw request value only reaches res.redirect
// inside the `if (ALLOWED.has(...))` block, so it is validated before use.
export function guardedRedirect(req: Request, res: Response): void {
  if (ALLOWED.has(req.query.next as string)) {
    res.redirect(req.query.next as string);
  }
}

const ALLOWED_ARR = ['/account', '/billing'];

// Inline allow-list guard using Array.includes.
export function guardedRedirectIncludes(req: Request, res: Response): void {
  if (ALLOWED_ARR.includes(req.body.to as string)) {
    res.redirect(req.body.to as string);
  }
}

Suppressing this rule

If a finding is a genuine false positive, scope the suppression to the exact line and leave a reason, never disable the rule project-wide. Disable directives are line-scoped by design.

// oauthlint-disable-next-line auth.flow.open-redirect -- <reason>

References

https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html ↗https://cwe.mitre.org/data/definitions/601.html ↗