Web Security

Open Redirect Vulnerabilities: Detection and Prevention

Learn how attackers exploit open redirects for phishing, the flaws in blacklist-based defenses, and safe redirect patterns for Next.js and Express.

March 9, 20266 min readShipSafer Team

Open Redirect Vulnerabilities: Detection and Prevention

Open redirect vulnerabilities are deceptively simple — an application takes a URL from user input and redirects the browser there without verifying that the destination is safe. The result is that a link carrying your trusted domain name delivers victims to an attacker-controlled page. Phishing campaigns, credential harvesting, and OAuth token theft all exploit this class of bug routinely.

How Open Redirects Are Abused

The canonical attack scenario looks like this:

https://app.example.com/login?next=https://evil.example.com/fake-login

A user who clicks that link lands on your legitimate login page, authenticates, and is then forwarded to a convincing replica that captures their session cookie or password. The victim sees app.example.com in the initial URL — enough to override suspicion.

Beyond phishing, open redirects are leveraged as a second step in more complex chains:

  • OAuth redirect_uri abuse — Some providers apply only loose validation on the redirect_uri parameter. An open redirect on the legitimate client origin can be combined with a crafted authorization request to leak OAuth authorization codes.
  • SSRF amplification — In server-side redirect handling, an open redirect may cause the server itself to fetch an internal resource.
  • Reputation laundering — Spammers use redirects through reputable domains to pass email and ad-network spam filters.

Why Blacklists Fail

The instinctive fix is a blacklist: reject any next parameter that contains evil.com. Every blacklist implementation in the wild has been bypassed. The URL specification is rich enough to guarantee this.

Common bypasses:

# Protocol-relative URLs — many blacklists only check for http:// and https://
//evil.example.com/path

# Backslash normalization — browsers parse \\ as /
https://evil.example.com\@app.example.com/

# URL encoding
https://evil.example.com%2F@app.example.com/

# Double encoding
https://evil.example.com%252F@app.example.com/

# Null byte injection (rare, older parsers)
https://app.example.com%00.evil.example.com/

# Unicode normalization
https://аpp.example.com/   (Cyrillic 'а', not Latin 'a')

Any solution that tries to match strings against a deny-list will eventually be bypassed by a new encoding trick. Defense must operate at the semantic level, not the lexical level.

The Correct Approach: Allowlists and Relative Paths

Option 1 — Relative paths only

The strongest defense is to reject any next value that is not a path relative to the current origin. A relative path cannot redirect to an external domain.

function isSafeRedirect(url: string): boolean {
  // Must start with / but not // (protocol-relative)
  if (!url.startsWith('/') || url.startsWith('//')) {
    return false;
  }
  // Parse to catch encoded tricks
  try {
    const parsed = new URL(url, 'https://app.example.com');
    return parsed.hostname === 'app.example.com';
  } catch {
    return false;
  }
}

Always parse the URL before testing it. String prefix checks on raw input miss encoded variants.

Option 2 — Explicit allowlist of safe destinations

When you genuinely need to redirect to a set of known external partners:

const ALLOWED_REDIRECT_ORIGINS = new Set([
  'https://app.example.com',
  'https://billing.example.com',
  'https://docs.example.com',
]);

function isSafeRedirect(url: string): boolean {
  try {
    const parsed = new URL(url);
    return ALLOWED_REDIRECT_ORIGINS.has(parsed.origin);
  } catch {
    return false;
  }
}

The key is comparing the parsed origin (scheme + host + port), not doing a substring match on the raw string.

Safe Redirect Patterns in Next.js

Next.js server actions and route handlers both encounter this pattern. Here is a safe implementation using the App Router:

// app/actions/auth.ts
'use server';

import { redirect } from 'next/navigation';

function isSafeNext(next: string | null): string {
  if (!next) return '/dashboard';
  if (!next.startsWith('/') || next.startsWith('//')) return '/dashboard';
  try {
    // Resolve against origin to catch encoded attacks
    const base = 'https://app.example.com';
    const resolved = new URL(next, base);
    if (resolved.origin !== base) return '/dashboard';
    return resolved.pathname + resolved.search + resolved.hash;
  } catch {
    return '/dashboard';
  }
}

export async function signIn(formData: FormData) {
  const nextParam = formData.get('next');
  const next = typeof nextParam === 'string' ? nextParam : null;
  // ... authenticate user ...
  redirect(isSafeNext(next));
}

Note that redirect() from next/navigation always produces an absolute redirect to the same origin when given a path, so this is already safe as long as your path extraction is correct. The danger is in rolling your own Response with a Location header.

Safe Redirect Patterns in Express

import express, { Request, Response } from 'express';
import { URL } from 'url';

const APP_ORIGIN = 'https://app.example.com';

function safeRedirect(res: Response, destination: string): void {
  let target = '/';
  try {
    const parsed = new URL(destination, APP_ORIGIN);
    if (parsed.origin === APP_ORIGIN) {
      target = parsed.pathname + parsed.search + parsed.hash;
    }
  } catch {
    // leave target as '/'
  }
  res.redirect(302, target);
}

app.get('/login', (req: Request, res: Response) => {
  const next = typeof req.query.next === 'string' ? req.query.next : '/';
  // ... authenticate ...
  safeRedirect(res, next);
});

Detecting Open Redirects

Automated scanning

Most DAST tools (OWASP ZAP, Burp Suite Scanner, Nuclei) include open redirect checks. The Nuclei template library has a dedicated category. Run these as part of your CI/CD pipeline against a staging environment.

A quick manual check: search your codebase for any code that reads from request parameters and passes values directly to redirect functions.

# Search for common redirect sinks taking user input
grep -rn "res.redirect\|router.push\|location.href\|window.location" src/ \
  | grep -i "req.query\|req.params\|searchParams\|getParam"

Code review checklist

When reviewing a PR that touches authentication flows, password reset, or post-login redirect logic, ask:

  • Does the code use new URL() to parse the destination before comparing it?
  • Is the comparison against origin (not a substring of the full URL)?
  • Is there a safe default if validation fails?
  • Are there integration tests that submit //evil.com and https://evil.com and verify the redirect goes to the fallback?

Testing Your Fix

Write explicit tests for bypass patterns:

describe('isSafeRedirect', () => {
  const safe = ['/dashboard', '/settings?tab=profile', '/'];
  const unsafe = [
    'https://evil.com',
    '//evil.com',
    '/\\evil.com',
    'https://evil.com%2F@app.example.com/',
    'javascript:alert(1)',
    '',
  ];

  safe.forEach(url => {
    it(`allows ${url}`, () => expect(isSafeRedirect(url)).toBe(true));
  });

  unsafe.forEach(url => {
    it(`blocks ${url}`, () => expect(isSafeRedirect(url)).toBe(false));
  });
});

Open redirects are rated medium severity in isolation but high severity when chained with OAuth flows or used in targeted phishing against your user base. The fix is small — a single well-written validation function — and the test surface is well-defined. There is no reason to ship this vulnerability.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.