DEV Community

Cover image for Open Redirect Vulnerabilities in PHP and Laravel — When Your Own Domain Becomes the Attack
Nchiminyi — Founder, Kriosa
Nchiminyi — Founder, Kriosa

Posted on Originally published at kriosa.com

Open Redirect Vulnerabilities in PHP and Laravel — When Your Own Domain Becomes the Attack

The attacker does not need to trick users into visiting evil.com. They trick them into visiting yoursite.com which redirects them there automatically.

This is the seventeenth article in a series on PHP and Laravel application security.

So far we have covered:

  • Detecting SQL injection attempts in PHP logs
  • Why URL encoding blinds most PHP security checks
  • The decode bomb problem with unlimited URL decoding
  • Why parameterized queries are the only real fix for SQL injection
  • XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • How attackers enumerate your Laravel app before exploiting it
  • File upload security — the file that isn't what it claims to be
  • Path traversal in PHP — how ../ escapes your application
  • Command injection in PHP — when exec() becomes an attack surface
  • Broken access control in Laravel — why being logged in is not enough
  • Secrets in Laravel — why .env is only the beginning
  • Session security in PHP — what most developers get wrong
  • Rate limiting in Laravel and PHP — how to stop brute force before it starts
  • Security headers in PHP and Laravel — the lines that harden every response
  • IDOR in PHP and Laravel — when changing one number exposes someone else's data
  • Mass assignment in PHP and Laravel — when user input becomes more than it should

Every article in this series follows the same principle understand the attack before you try to stop it.

Open redirects are sometimes dismissed as low severity. That framing misses the real risk. A redirect vulnerability that sends users from your trusted domain to an attacker's phishing page is not low severity it is a trust weaponization attack that uses your reputation against your own users.

What an Open Redirect Is

Your application has a login page. After successful login it redirects the user back to where they were:

https://yoursite.com/login?redirect=/dashboard
Enter fullscreen mode Exit fullscreen mode

Your code:

header('Location: ' . $_GET['redirect']);
exit;
Enter fullscreen mode Exit fullscreen mode

For a legitimate user redirect=/dashboard works perfectly. They log in and land on their dashboard.

An attacker crafts this URL and sends it to a victim:

https://yoursite.com/login?redirect=https://evil.com/fake-login
Enter fullscreen mode Exit fullscreen mode

The victim sees yoursite.com in the link. They trust it. They click it. They log into your application. Your application immediately redirects them to evil.com/fake-login a page that looks identical to yours and asks them to enter their credentials again.

The victim never saw evil.com in a URL they were asked to trust. They only saw yoursite.com.

That is an open redirect. Your domain's trust, redirected against your users.

Why Open Redirects Are More Dangerous Than They Look

They weaponize your domain's reputation.
A direct link to evil.com looks suspicious. Email spam filters flag it. Users hesitate. A link through yoursite.com inherits your domain's trust. It passes filters. Users click without hesitation.

They chain with other vulnerabilities.
An open redirect combined with an OAuth flow can allow an attacker to steal authorization codes. Combined with a password reset flow it may allow stealing reset tokens. The standalone impact varies by context in some cases it is genuinely low. But in authentication flows and OAuth implementations it can contribute to account takeover when chained with other weaknesses.

They are permanent phishing infrastructure.
Once an attacker discovers an open redirect on your domain they have a phishing asset tied to your reputation for as long as the vulnerability exists.

The Four Places Open Redirects Appear

Login redirect parameters — the most common:

/login?redirect=https://evil.com
/login?return_to=https://evil.com
/login?next=https://evil.com
Enter fullscreen mode Exit fullscreen mode

Logout redirect parameters:

/logout?redirect=https://evil.com
Enter fullscreen mode Exit fullscreen mode

Error page redirects:

/404?return=https://evil.com
Enter fullscreen mode Exit fullscreen mode

Link trackers and URL shorteners:

/go?url=https://evil.com
/track?link=https://evil.com
/redirect?to=https://evil.com
Enter fullscreen mode Exit fullscreen mode

The Wrong Fixes

Most developers who know about open redirects implement fixes that do not work. Understanding why they fail is as important as knowing the correct solution.

Wrong fix 1 — Checking for http

$redirect = $_GET['redirect'];
if (strpos($redirect, 'http') === false) {
    header('Location: ' . $redirect);
    exit;
}
header('Location: /dashboard');
exit;
Enter fullscreen mode Exit fullscreen mode

An attacker sends:

//evil.com/phishing
Enter fullscreen mode Exit fullscreen mode

Protocol-relative URLs begin with //. The browser treats them as HTTPS or HTTP depending on the current protocol. Your check looks for http and finds nothing. The redirect executes.

Wrong fix 2 — Checking for your domain name in the string.

$redirect = $_GET['redirect'];
if (strpos($redirect, 'yoursite.com') !== false) {
    header('Location: ' . $redirect);
    exit;
}
header('Location: /dashboard');
exit;
Enter fullscreen mode Exit fullscreen mode

An attacker sends:

https://evil.com/yoursite.com/phishing
Enter fullscreen mode Exit fullscreen mode

or:

https://yoursite.com.evil.com/phishing
Enter fullscreen mode Exit fullscreen mode

Your check finds yoursite.com in the string. The redirect executes.

Wrong fix 3 — Partial parse_url() validation:

$redirect = $_GET['redirect'];
$parsed = parse_url($redirect);

if ($parsed['host'] === 'yoursite.com') {
    header('Location: ' . $redirect);
    exit;
}
header('Location: /dashboard');
exit;
Enter fullscreen mode Exit fullscreen mode

URL parsing behavior can differ between PHP and browsers in edge cases involving characters like \ and @. Checking a single parsed component without validating scheme and ensuring the full URL resolves as expected is not sufficient protection. Complete validation requires checking scheme, host, and ensuring the URL does not contain bypass characters.

Wrong fix 4 — Blacklisting specific domains:

The number of domains an attacker can register is unlimited. Blacklisting specific domains is not a security control.

The Correct Fixes

Fix 1 — Whitelist approach — the strongest fix.

Never accept arbitrary URLs. Accept a key that maps to a known safe destination.

$allowedRedirects = [
    'dashboard' => '/dashboard',
    'profile'   => '/profile',
    'settings'  => '/settings',
    'orders'    => '/orders',
];

$key = $_GET['redirect'] ?? 'dashboard';
$redirect = $allowedRedirects[$key] ?? '/dashboard';

header('Location: ' . $redirect);
exit;
Enter fullscreen mode Exit fullscreen mode

The attacker cannot redirect to an external URL because the destination is never derived from user input. This eliminates the attack surface entirely.

Fix 2 — Relative URL validation.

If you must accept user-supplied paths restrict them to paths relative to your own domain.

function isSafeRedirect(string $url): bool
{
    // Must not be protocol-relative
    if (str_starts_with($url, '//')) {
        return false;
    }

    // Must not contain a scheme
    if (preg_match('/^[a-zA-Z][a-zA-Z0-9+\-.]*:/', $url)) {
        return false;
    }

    // Must start with a forward slash
    if (!str_starts_with($url, '/')) {
        return false;
    }

    return true;
}

$redirect = $_GET['redirect'] ?? '/dashboard';

if (!isSafeRedirect($redirect)) {
    $redirect = '/dashboard';
}

header('Location: ' . $redirect);
exit;
Enter fullscreen mode Exit fullscreen mode

A URL that starts with / and contains no scheme is always relative to the current domain. There is no way to redirect to an external site within these constraints.

Fix 3 — Domain validation for absolute URLs

If you must allow absolute URLs validate both scheme and host precisely

function isTrustedRedirect(string $url, string $trustedHost): bool
{
    $parsed = parse_url($url);

    if (empty($parsed['host'])) {
        return false;
    }

    if (!in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
        return false;
    }

    $host = strtolower($parsed['host']);
    $trusted = strtolower($trustedHost);

    // Exact match or confirmed subdomain only
    return $host === $trusted || str_ends_with($host, '.' . $trusted);
}

$redirect = $_GET['redirect'] ?? '/dashboard';

if (!isTrustedRedirect($redirect, 'yoursite.com')) {
    $redirect = '/dashboard';
}

header('Location: ' . $redirect);
exit;
Enter fullscreen mode Exit fullscreen mode

The host must exactly match your domain or be a confirmed subdomain. The scheme must be http or https.

A note on defense in depth:

Even with validation in place it is worth adding a fallback default. If validation fails for any reason unexpected input format, edge case in parse_url() the application falls back to a known safe destination rather than proceeding with an unvalidated URL.

$redirect = $_GET['redirect'] ?? '/dashboard';
$safe = isSafeRedirect($redirect) ? $redirect : '/dashboard';
header('Location: ' . $safe);
exit;
Enter fullscreen mode Exit fullscreen mode

Open Redirects in Laravel

Laravel's redirect() helper is convenient. The vulnerability appears when user input reaches it without validation.

// Dangerous
public function login(Request $request)
{
    // ... authenticate user
    $redirect = $request->input('redirect', '/dashboard');
    return redirect($redirect);
}
Enter fullscreen mode Exit fullscreen mode

Fix 1 — Use redirect()->intended() for post-login redirects.

Laravel's intended redirect system stores the originally requested URL in the session before redirecting to login.

// In your authentication middleware
return redirect()->guest('/login');

// After successful login
return redirect()->intended('/dashboard');
Enter fullscreen mode Exit fullscreen mode

redirect()->intended() reads the stored URL from the session not from user input. The intended URL was stored server-side before the user ever saw the login page. This is the correct pattern for post-login redirects in Laravel.

Fix 2 — Whitelist in Laravel:

public function login(Request $request)
{
    // ... authenticate user

    $allowedRedirects = [
        'dashboard' => '/dashboard',
        'profile'   => '/profile',
        'settings'  => '/settings',
        'orders'    => '/orders',
    ];

    $key = $request->input('redirect', 'dashboard');
    $redirect = $allowedRedirects[$key] ?? '/dashboard';

    return redirect($redirect);
}
Enter fullscreen mode Exit fullscreen mode

Fix 3 — Relative URL validation in Laravel.

public function login(Request $request)
{
    // ... authenticate user

    $redirect = $request->input('redirect', '/dashboard');

    if (!str_starts_with($redirect, '/') ||
        str_starts_with($redirect, '//') ||
        preg_match('/^[a-zA-Z][a-zA-Z0-9+\-.]*:/', $redirect)) {
        $redirect = '/dashboard';
    }

    return redirect($redirect);
}
Enter fullscreen mode Exit fullscreen mode

Fix 4 — Signed URLs for trusted flows.

For email links, password reset flows, and OAuth callbacks use Laravel's signed URLs.

// Generate a signed URL that includes the redirect destination
$url = URL::signedRoute('post.login.redirect', [
    'destination' => '/orders/1042'
]);
Enter fullscreen mode Exit fullscreen mode
// Verify the signature before redirecting
public function postLoginRedirect(Request $request)
{
    if (!$request->hasValidSignature()) {
        abort(403);
    }

    $destination = $request->input('destination', '/dashboard');

    // Validate even after signature check
    if (!str_starts_with($destination, '/') ||
        str_starts_with($destination, '//')) {
        $destination = '/dashboard';
    }

    return redirect($destination);
}
Enter fullscreen mode Exit fullscreen mode

Signed URLs cannot be tampered with the cryptographic signature covers the entire URL including the destination parameter. An attacker who changes the destination invalidates the signature.

Open Redirects in OAuth Flows

OAuth flows are where open redirects can reach critical severity.

When your application implements OAuth login the authorization server redirects back to your application with an authorization code. If your application uses a user-supplied redirect_uri that is not validated against a registered allowlist an attacker can potentially intercept that authorization code:

// Dangerous
$callbackUri = $_GET['redirect_uri'];
header('Location: ' . $callbackUri . '?code=' . $authCode);
Enter fullscreen mode Exit fullscreen mode

OAuth specifications require redirect URIs to be registered in advance and validated with exact string matching not prefix or suffix matching.

$registeredCallbacks = [
    'https://yoursite.com/oauth/callback',
    'https://yoursite.com/oauth/mobile/callback',
];

$callbackUri = $_GET['redirect_uri'] ?? '';

if (!in_array($callbackUri, $registeredCallbacks, true)) {
    http_response_code(400);
    die('Invalid redirect URI.');
}
Enter fullscreen mode Exit fullscreen mode

Any deviation from a registered URI should fail the OAuth flow entirely. The security of the OAuth flow depends on this check being exact.

The Open Redirect Checklist

For plain PHP

  • Never pass $_GET or $_POST values directly to header('Location: ...')
  • Use a whitelist of allowed redirect destinations mapped by key wherever possible
  • If you must accept user-supplied paths validate they are relative start with /, no scheme, no //
  • If you must allow absolute URLs validate scheme is http or https and host exactly matches your domain or a confirmed subdomain
  • Always fall back to a known safe default if validation fails for any reason
  • Never blacklist specific domains
  • OAuth redirect URIs must use exact string matching against a registered allowlist

For Laravel:

  • Use redirect()->intended() for post-login redirects never user-supplied parameters
  • Never pass $request->input('redirect') directly to redirect() without validation
  • Use a whitelist mapped by key for any redirect that accepts user input
  • Use signed URLs for email links, password reset flows, and OAuth callbacks
  • Validate relative URLs before passing to redirect()
  • Never pass user input to redirect()->away() without domain validation
  • OAuth redirect URIs must use exact string matching against a registered allowlist

Where Kriosa Fits

Open redirect attacks begin with reconnaissance. Before exploiting an open redirect an attacker probes for it — sending requests with external URLs in redirect parameters, testing which parameters are reflected in Location headers, checking whether your application follows redirects to external domains.

That probing behavior — systematic testing of redirect parameters across your application's endpoints — produces distinctive request patterns that differ from legitimate user navigation.

Kriosa monitors incoming requests for behavioral signals that precede open redirect exploitation unusual values in redirect parameters, systematic testing of URL-accepting endpoints, and request patterns that differ from normal user navigation. These signals appear in the XAI dashboard with an explanation of what was detected and why it was flagged.

Prevention through whitelists and validation stops your domain from being used as a redirect weapon. Detection through behavioral monitoring tells you when someone is checking whether it can be.

In Prolify post-login redirects use redirect()->intended() the destination is stored in the session before login, never taken from URL parameters.
In bellefull all redirects use a whitelist. Neither application passes user input directly to a redirect function.

Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php

Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.

The Series So Far
Article 1: What your PHP logs actually look like during a SQL injection attack
 Article 2: Why URL encoding can break PHP security checks
 Article 3: The decode bomb problem - why unlimited URL decoding can be its own vulnerability
 Article 4: Parameterized queries - the only real fix for SQL injection
 Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
 Article 6: How attackers enumerate your Laravel app before exploiting it
 Article 7: File upload security in PHP and Laravel
 Article 8: Path traversal in PHP - how ../ escapes your application
 Article 9: Command injection in PHP - when exec() becomes an attack surface
Article 10: Broken access control in Laravel - why being logged in is not enough
 Article 11: Secrets in Laravel - why .env is only the beginning
 Article 12: Session security in PHP - what most developers get wrong
Article 13: Rate limiting in Laravel and PHP - how to stop brute force before it starts
 Article 14: Security headers in PHP and Laravel - the lines that harden every response
Article 15: IDOR in PHP and Laravel - when changing one number exposes someone else's data
 Article 16: This article - mass assignment in PHP and Laravel and when user input becomes more than it should

  • Article 17: This article — open redirect vulnerabilities in PHP and Laravel

Top comments (0)