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
.envis 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
Your code:
header('Location: ' . $_GET['redirect']);
exit;
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
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
Logout redirect parameters:
/logout?redirect=https://evil.com
Error page redirects:
/404?return=https://evil.com
Link trackers and URL shorteners:
/go?url=https://evil.com
/track?link=https://evil.com
/redirect?to=https://evil.com
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;
An attacker sends:
//evil.com/phishing
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;
An attacker sends:
https://evil.com/yoursite.com/phishing
or:
https://yoursite.com.evil.com/phishing
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;
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;
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;
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;
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;
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);
}
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');
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);
}
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);
}
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'
]);
// 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);
}
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);
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.');
}
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
$_GETor$_POSTvalues directly toheader('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
httporhttpsand 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 toredirect()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)