June 9, 2026
The Laravel Guide to SSRF Defense That Survives Redirects
A hostname allowlist helps at input time. The real attack starts after your server sends the request.
Hafiq Iqmal
8 min read
The dangerous version of SSRF in Laravel rarely starts with a cartoonish file:///etc/passwd payload. It usually starts with a feature request that sounds normal: let users import an avatar from a URL, validate a webhook destination, preview a document from a partner endpoint, fetch an Open Graph card or sync a feed. Somebody adds a host allowlist, checks that the scheme is https, and moves on.
That check feels responsible. It is also incomplete.
The gap is simple: you validate a string, but your server executes a network conversation where redirects, DNS resolution and infrastructure behavior all happen after validation. Which means a Laravel app can approve a URL that looks safe in your controller and still make a request you would never have approved if you had inspected the actual destination at runtime.
This is the part many teams miss: the allowlist is not the control, just one stage in the control.
The code that looks careful is still too trusting
Most insecure SSRF defenses in Laravel are not reckless. They are half-right.
They usually look something like this:
<?php
use Illuminate\Support\Facades\Http;
$url = $request->string('avatar_url')->toString();
$host = parse_url($url, PHP_URL_HOST);
abort_unless(in_array($host, [
'images.example-cdn.com',
'media.partner.com',
], true), 422);
$response = Http::get($url);<?php
use Illuminate\Support\Facades\Http;
$url = $request->string('avatar_url')->toString();
$host = parse_url($url, PHP_URL_HOST);
abort_unless(in_array($host, [
'images.example-cdn.com',
'media.partner.com',
], true), 422);
$response = Http::get($url);At first glance, that feels decent:
- The host is restricted.
- Only known domains pass.
- No arbitrary internal IPs.
Still, the code only proves one thing: the original string contained a host you liked. It does not prove where the request actually went. It does not prove what that host resolved to at request time. It does not prove the next hop stayed on the same host after a 302. And it definitely does not prove your app cannot be walked into a link-local or private address once the HTTP client takes over.
That last part matters because SSRF is not just about one bad URL. It is about turning your app into a network client on the attacker's behalf.
OWASP makes this distinction clearly. The risk is not limited to one protocol, and the request your application performs can move beyond the input you initially inspected. That is why their guidance separates application-layer validation from network-layer restriction. You need both.
Redirects kill the illusion of a safe allowlist
The cleanest bypass is also the one developers forget first: the request is approved against one destination, then redirected to another.
OWASP's SSRF cheat sheet says to disable redirect following in the web client to prevent bypassing input validation. That advice is not theoretical. It exists because redirect chains let an attacker hand you a trusted-looking domain as the visible front door while the actual network request continues somewhere else.
Guzzle, which sits under Laravel's HTTP client, follows redirects by default. The docs show allow_redirects enabled with a default redirect chain and document allow_redirects => false as the switch that turns it off.
That means the naive Laravel code above does this:
- Approves https://media.partner.com/file/123
- Sends the request
- Follows a redirect response automatically
- Ends up somewhere you never validated
Which is fine until it isn't.
If your importer, previewer or webhook verifier only checked the original URL string, the redirect step quietly moves security policy out of your code and into the remote server's behavior. That is not a policy. That is outsourcing.
In Laravel, the minimum fix is explicit:
<?php
use Illuminate\Support\Facades\Http;
$response = Http::withOptions([
'allow_redirects' => false,
])->get($url);<?php
use Illuminate\Support\Facades\Http;
$response = Http::withOptions([
'allow_redirects' => false,
])->get($url);This does not solve SSRF on its own. It only stops one class of bypass. But it closes an important hole because now your application sees the 30x response and can decide what to do next instead of letting the client wander off on its own.
That choice matters more than people think. If a remote service wants to redirect your server to a new location, that new location should go through the same policy checks as the original target. No exceptions because the remote side asked politely.
Domain allowlists break at DNS time, not string time
A hostname allowlist is better than nothing. It is still not enough.
OWASP calls out a specific failure mode here: DNS pinning. The short version is ugly. A domain that passes your allowlist check can still resolve to an internal or non-public IP when the real request happens. Your string comparison passes because the hostname looks fine. The socket connection is where the problem shows up.
That is why OWASP says a domain-name allowlist remains vulnerable when the business code later performs DNS resolution. Their recommendation is to do additional checks around resolution and monitor what allowlisted domains resolve to.
This is the shift that tends to improve a Laravel implementation fast: stop treating the hostname as the only security boundary. The IPs behind it matter just as much.
PHP gives you the plumbing for this with dns_get_record(), which fetches DNS resource records for a hostname. You do not need to build a perfect DNS engine in application code, but you do need to look at the resolved addresses before the outbound request leaves your system.
Here is the shape of that check in PHP:
<?php
function resolvePublicAddresses(string $hostname): array
{
$records = dns_get_record($hostname, DNS_A + DNS_AAAA);
return collect($records)
->map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null)
->filter()
->values()
->all();
}<?php
function resolvePublicAddresses(string $hostname): array
{
$records = dns_get_record($hostname, DNS_A + DNS_AAAA);
return collect($records)
->map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null)
->filter()
->values()
->all();
}The example above only resolves addresses. The actual policy decision comes next, and this is where a lot of defensive code gets lazy. You need to reject anything that lands in private, loopback, link-local or otherwise non-public ranges. You also need to decide whether your feature should ever talk to raw IPs at all. In most product code, the answer should be no.
So the better mental model is:
Input-time checks
- Start with the scheme. Is it allowed?
- Compare the hostname against the approved set.
- No full user-supplied URL when an identifier would do.
Runtime checks
- What do A and AAAA resolution return right now?
- Reject any address that is private, loopback or link-local.
- Then watch the redirect path for host or protocol changes.
Network checks
- Can this workload reach internal networks at all?
- Cloud metadata endpoints should not be reachable from a user-driven fetch path.
- And yes, egress policy has to enforce what the code claims to enforce.
That layered view is less elegant than a single in_array() check. It is also much closer to how the attack actually works.
Complete user-supplied URLs are often the wrong input
OWASP says not to accept complete URLs from the user when you can avoid it because URLs are hard to validate safely and parsers can be abused. That advice lands harder once you have maintained one of these features in production.
A lot of Laravel code accepts a full URL because it seems flexible. Flexible inputs are great right up until the moment they create a parsing problem, a protocol problem, a redirect problem and a network-boundary problem in one line of code.
If the feature does not truly need a full URL, do not accept one.
Safer alternatives look like this:
- Accept a partner ID and derive the fetch target server-side.
- Accept a domain from a pre-registered integration record, not from an arbitrary request body.
- Accept a signed callback registration flow, then store the validated target once and reuse that record later.
This is where senior engineers usually save the most risk with the least code. They stop arguing about URL validation edge cases and change the contract so the dangerous input stops existing in the first place.
Not every feature lets you do that. Webhooks are the annoying example. Sometimes the product really does need a user-controlled outbound target. In that case, treat outbound fetching as a dedicated security-sensitive capability, not as an incidental Http::post() inside a controller.
What I would actually ship in a Laravel app
For a real Laravel codebase, I would not scatter SSRF checks across controllers, jobs and service classes. I would put outbound fetch policy in one place and force every risky feature through it.
A practical sequence looks like this:
- Parse the target and reject unsupported schemes, which in most apps means https only.
- Reject raw IP input unless the business case absolutely requires it.
- If the feature works with pre-approved partners, compare the hostname against a stored allowlist using strict comparison.
- Resolve A and AAAA records and reject any result that is not public.
- Send the request with redirects disabled.
- If the remote side returns a redirect, re-run the full policy against the new location before following anything.
- Put egress controls around the workload so a missed validation does not become unrestricted internal reachability.
That sequence sounds heavier than a one-liner. It is heavier. SSRF defense is one of those places where the boring answer is also the expensive one.
Here is the architectural split I like:
Controller
- Validates user intent.
- Calls an outbound fetch service.
- Never sends arbitrary URLs directly.
Outbound fetch service
- Normalize the input first.
- Resolve DNS, then check the returned addresses.
- Send the request with restricted client options.
- Redirects get denied unless the next hop passes policy again.
Infrastructure
- Blocks access to sensitive internal destinations by default.
- Restrict outbound routes for the workload.
- Keep metadata endpoints out of reach.
The point of this split is not style. It is auditability. When the next feature wants to fetch "just one small remote file," you already have a place where that decision has to go through policy instead of convenience.
Cloud metadata is why network policy cannot be optional
AWS is the easiest concrete example because the target is so well known. EC2 instance metadata lives at 169.254.169.254, and AWS documents the IMDSv2 flow as a token-based process. That token requirement improved the security story, but it did not make SSRF irrelevant. It changed the conditions of the attack.
This distinction matters.
A team will often read "IMDSv2 requires a token" and mentally downgrade the metadata endpoint from "dangerous" to "handled." That is the wrong conclusion. It is still a sensitive internal target your app usually has no reason to touch from a user-driven fetch path. If your outbound image fetcher, webhook verifier or document preview worker can reach link-local addresses, you are betting application-layer correctness against infrastructure exposure. Bad trade.
OWASP's advice to pair application controls with network-layer restrictions is the sane approach here. If the workload that downloads avatars never needs access to internal admin services or metadata endpoints, make that true in the network, not just in code comments.
This is where solo leads and small teams get trapped. They assume network policy is a later maturity problem, so they keep stuffing validation logic into the app. Then one new feature lands, one library behavior changes, one exception path bypasses the fetch service, and suddenly the "safe" workload can see far more of the network than anyone meant it to.
The fix is a policy, not a helper method
A lot of SSRF defenses fail because they are implemented as a helper instead of a boundary.
Helpers are easy to skip. Boundaries are harder.
If you keep SSRF prevention as "remember to call this validator before `Http
::get
()`," you will eventually miss a call site. Maybe in a queued job. Maybe in a package integration. Maybe in the one emergency patch that went out on Friday evening because the partner API changed and everybody wanted the outage gone before dinner.
A better pattern is a dedicated outbound client abstraction with a narrow interface:
- Fetch approved partner resource
- Deliver webhook to registered endpoint
- Download remote media for processing
- Nothing else
Then enforce policy there.
For especially risky flows, I would go one step further and make the service accept structured input instead of raw URLs. Something like {integration_id, path} is easier to reason about than "here is an arbitrary URL string, good luck." You lose a little flexibility. You gain a control surface that a reviewer can actually understand.
That trade is almost always worth it.
What this means for Laravel teams right now
If your current SSRF protection is "we check the host before calling Http::get()," you do not have SSRF protection yet. You have a first checkpoint.
The minimum production-grade version should include:
- Strict scheme and host policy
- DNS-based IP checks for the resolved destination
- Redirects disabled by default
- Re-validation before any redirect follow
- Egress controls that keep sensitive internal targets out of reach
You do not need a giant security platform to get there. You do need to stop treating outbound fetches like harmless utility code.
Because once your Laravel app becomes a network client for user-controlled destinations, the interesting security decision is no longer "did the string look safe?" It is "what could this workload actually reach after the string passed?"
That is the question that survives redirects.