Blog
Don't Redirect Wherever They Tell You
- security
- laravel
- php
- typescript
- node
Every app eventually grows a ?next= parameter. Somebody hits a protected page, you bounce them to login, and after they sign in you send them back where they were going.
It is a nice touch. It is also one line away from turning your domain into a free phishing service.
The one-line version
This is the code that ships when everybody is in a hurry:
public function store(LoginRequest $request)
{
$request->authenticate();
return redirect($request->input('next', '/dashboard'));
}And the Express flavor:
app.post("/login", async (req, res) => {
await signIn(req);
res.redirect(String(req.query.next ?? "/dashboard"));
});Both helpers will happily accept a full URL. So this link works exactly the way an attacker wants:
https://yourapp.com/login?next=https://yourapp-login.example/session-expired
The link points at your real domain. The login page is your real login page. The user signs in for real. Then you hand them off to a lookalike page that says "session expired, please sign in again," and they type their password into somebody else's form.
Nothing in your logs looks wrong. You just did a redirect.
"Just check that it starts with a slash"
That is the right instinct and the wrong implementation. Browsers do not read URLs the way your if statement does.
//evil.exampleis a protocol-relative URL. It starts with a slash. It goes toevil.example./\evil.examplealso lands onevil.example, because browsers treat a backslash like a forward slash inhttpandhttpsURLs./<tab>/evil.examplegets the tab stripped out during parsing, which leaves//evil.example.javascript:alert(1)does not start with a slash, but it shows up the moment someone "fixes" the check with a looser rule.
You do not have to take my word for the weird ones. Node's URL class follows the same WHATWG URL standard browsers use, so you can check it in a REPL:
new URL("/\\evil.example", "https://yourapp.com").href;
// "https://evil.example/"
new URL("/\t/evil.example", "https://yourapp.com").href;
// "https://evil.example/"If your validation and the browser disagree about where a URL goes, the browser wins.
In TypeScript, let the parser decide
Since Node ships the same parser, the cleanest check is to resolve the value against your own origin and see if it is still yours:
const APP_ORIGIN = new URL("https://yourapp.com");
export function safeRedirect(next: unknown, fallback = "/dashboard"): string {
if (typeof next !== "string" || next === "") return fallback;
let target: URL;
try {
target = new URL(next, APP_ORIGIN);
} catch {
return fallback;
}
if (target.origin !== APP_ORIGIN.origin) return fallback;
// Return the full URL, not pathname + search + hash.
// Same-origin can pass while pathname is still "//evil.example".
return target.href;
}Two details matter here.
You compare origin, not "does the string contain my domain." https://yourapp.com.evil.example contains your domain. It is not your domain.
And you return target.href, the fully serialized same-origin URL. Do not rebuild a relative location from pathname + search + hash. The origin check can pass and the pathname can still start with //:
const next = "https://yourapp.com//evil.example";
const target = new URL(next, "https://yourapp.com");
target.origin; // "https://yourapp.com" (looks fine)
target.pathname; // "//evil.example" (protocol-relative if you return it)Dot segments normalize into the same shape:
new URL("/foo/..//evil.example", "https://yourapp.com").pathname;
// "//evil.example"Put that pathname in a Location header and the browser treats it as a new protocol-relative URL and leaves your site. Returning target.href keeps the origin you already verified, so Location stays on https://yourapp.com even when the path looks weird.
In PHP, be stricter instead of clever
PHP's parse_url() is not a browser-compatible parser, so I would not lean on it to predict where a browser will end up. For the login-redirect case you usually only need same-site paths anyway, so reject anything that is not a plain single-slash path:
function safeRedirectPath(mixed $next, string $fallback = '/dashboard'): string
{
if (! is_string($next) || ! str_starts_with($next, '/') || str_starts_with($next, '//')) {
return $fallback;
}
// No backslashes, no control characters. Browsers rewrite both.
if (preg_match('/[\x00-\x1F\x7F\\\\]/', $next)) {
return $fallback;
}
return $next;
}Then the controller gets boring again:
return redirect(safeRedirectPath($request->input('next')));Yes, this rejects some technically valid paths with odd characters in them. That is fine. Nobody's legitimate post-login destination needs a tab character.
Laravel already has a better default
If you are using Laravel's auth middleware, you may not need a ?next= parameter at all. When a guest hits a protected route, the framework stores the URL they wanted in the session, and after login you send them there:
return redirect()->intended('/dashboard');The destination came from your server's own record of the request, not from a query string someone pasted into an email. That is a much better trust story.
Just do not undo it by stuffing user input into the session yourself:
// Same bug, extra steps.
session(['url.intended' => $request->input('next')]);And when you genuinely mean to send someone off-site, say so on purpose with redirect()->away($url), using a URL your code picked, not one the request handed you.
When you really do need other domains
Sometimes the app legitimately sends people between a few hosts. Marketing site, app, docs, a billing portal.
Use an allowlist of exact hosts:
const ALLOWED_HOSTS = new Set(["yourapp.com", "docs.yourapp.com", "billing.yourapp.com"]);
function isAllowed(next: string): boolean {
try {
const url = new URL(next);
return url.protocol === "https:" && ALLOWED_HOSTS.has(url.hostname);
} catch {
return false;
}
}Exact matches. Not endsWith("yourapp.com"), because notyourapp.com ends with that too. If you must allow subdomains, check for .yourapp.com with the leading dot, and then ask yourself whether every subdomain you have ever pointed at a third party is still yours.
OAuth makes this worse
Open redirects are annoying on a login page. Inside an OAuth flow they can leak authorization codes or tokens to whoever controls the destination.
That is why the OAuth security best practice (RFC 9700) calls for exact string matching on registered redirect URIs. If you run an OAuth server, or you are configuring one in a provider dashboard, do not register wildcards because it was easier for the staging environment.
Put the weird inputs in a test
This is the kind of check that gets "simplified" six months later by someone who never saw the tab trick. Lock it down:
it('never leaves the site', function (string $next) {
expect(safeRedirectPath($next))->toBe('/dashboard');
})->with([
'https://evil.example',
'//evil.example',
'/\\evil.example',
"/\t/evil.example",
'javascript:alert(1)',
]);
it('keeps real paths', function () {
expect(safeRedirectPath('/orders/42?tab=items'))->toBe('/orders/42?tab=items');
});And for the TypeScript helper, pin the pathname footgun so nobody "cleans up" target.href back into a relative rebuild:
import assert from "node:assert/strict";
assert.equal(
safeRedirect("https://yourapp.com//evil.example"),
"https://yourapp.com//evil.example",
);
assert.equal(
safeRedirect("/foo/..//evil.example"),
"https://yourapp.com//evil.example",
);
assert.equal(safeRedirect("//evil.example"), "/dashboard");
assert.equal(safeRedirect("/orders/42?tab=items"), "https://yourapp.com/orders/42?tab=items");Those first two inputs pass an origin check and still produce a //... pathname. If a future change returns the pathname again, these fail before that ships.
The short version
A redirect target is user input. Treat it like any other value from the request.
Prefer a destination your server remembered over one the request supplied. If you do take one from the request, allow URLs on your own origin and nothing else. Check it with the same parser the browser uses, or be strict enough that the parser differences cannot matter. When the parser says same-origin, return the full serialized URL, not a relative rebuild that can turn into //evil.example. And keep a test full of ugly inputs around so the next "cleanup" does not reopen it.
Your login page should send people back to your app. Not to wherever the link in the email said.