Applications often fetch URLs supplied indirectly by users. A link-preview service retrieves a page, an image importer downloads an avatar, or a webhook tester sends a request to a configured endpoint. The feature may look like ordinary URL handling, but it gives the requester influence over a network connection made with the application’s identity and network access.

If that influence is too broad, the application can become a route to destinations the requester could not reach directly. This class of weakness is server-side request forgery, usually shortened to SSRF.

The useful defensive model is simple: server-side fetching is a network capability, not just a string-processing operation. This article explains how to constrain that capability, why checking a URL once is often insufficient, and how to decide between a narrow allowlist and a more flexible outbound-fetching design.

The server can reach more than the user can

Suppose a service accepts a URL so it can generate a link preview:

user -> application -> supplied URL

The important trust boundary is the second arrow. The outbound connection comes from the application environment, not from the user’s browser.

That difference can matter. The application may be able to reach internal services, loopback interfaces, infrastructure endpoints, or network locations protected from direct internet access. Even when the fetched response is never returned verbatim, the request itself may still trigger an action or reveal information through timing, status, or other application behavior.

SSRF occurs when an attacker can make the server send a request to an unintended destination because the application does not sufficiently constrain where that request may go.

The threat model here is an untrusted or partially trusted requester who can influence an outbound destination. The controls below reduce that requester’s ability to turn the application into an unintended network client. They do not protect a server that is already compromised, make the destination’s content trustworthy, or replace authentication and authorization on internal services.

Start by defining the destinations the feature actually needs

The strongest design question is not “Which URLs should we block?” It is “Which destinations does this feature need to contact?”

For many features, the answer is surprisingly small. A payment callback verifier might need one provider API. A document importer may need a fixed set of partner domains. A service integration may communicate only with endpoints selected by administrators.

When the destination set is known, use an allowlist based on that requirement. Prefer identifiers that map to server-controlled destinations when users do not need arbitrary URLs at all.

For example, instead of accepting:

POST /import
source_url=https://...

a tightly scoped integration could accept:

POST /import
source=partner-a

The server maps partner-a to a destination stored in trusted configuration. The requester chooses an approved capability rather than supplying the network address itself.

This removes a large amount of URL-validation complexity because arbitrary hosts, schemes, and ports are no longer part of the feature contract.

A denylist is a weak model for destination control

It is tempting to accept any URL except obvious internal destinations. That approach starts with an open network capability and tries to enumerate everything that must not be reached.

The problem is broader than remembering a few familiar private address ranges. Hostnames can resolve to addresses that were not obvious from the original text. Address syntax can have multiple representations. IPv4 and IPv6 both matter. Network environments also contain organization-specific ranges and services that a generic denylist cannot know about.

A narrow allowlist reverses the burden. If the application only needs api.partner.example on HTTPS, other hosts do not need to be classified as good or bad; they are outside the feature’s allowed destination set.

That does not mean an allowlisted hostname should be trusted blindly. DNS resolution and redirects can still change where a connection goes. The allowlist defines policy, while connection-time checks enforce that policy.

Parse URLs as URLs, then validate their components

When a feature genuinely needs user-supplied URLs, use a well-tested URL parser and reason about the parsed result. Do not build security decisions from substring tests such as:

if "trusted.example" in url:
    fetch(url)

Text matching does not establish which host the network client will contact.

Validation should be explicit about the parts that affect the request. A typical HTTPS-only feature needs to decide at least:

  • which schemes are allowed;
  • which hostnames or address ranges are allowed;
  • whether non-default ports are permitted;
  • whether embedded credentials are meaningful or should be rejected;
  • what redirect behavior is permitted.

The exact policy depends on the feature. If only HTTP over TLS is required, accepting unrelated URL schemes creates capability without benefit. If only port 443 is required, arbitrary ports similarly expand the reachable surface.

Reject ambiguous input rather than trying to repair it into something acceptable. Normalization rules vary between URL parsers and network libraries, so the component you validate should correspond to the destination the HTTP client will actually use.

Validate the address that will receive the connection

Hostname validation alone is not enough when arbitrary or semi-arbitrary domains are accepted. A hostname is a name; the connection goes to an address produced by name resolution.

Consider this simplified sequence:

parse URL
    |
validate hostname
    |
resolve DNS
    |
connect to resolved address

If policy says the fetcher may contact public internet addresses but not internal network destinations, the resolved address must satisfy that policy too. Otherwise a hostname that looks acceptable can resolve to a destination the application should not reach.

The check also needs to cover every address family the client can use. Validating only an IPv4 result while the client can connect over IPv6 leaves the policy incomplete.

There is a second issue: avoid a gap where validation resolves one address but the HTTP client independently resolves the hostname again and connects somewhere else. DNS answers can change. A robust design ensures that the address checked against policy is the address used for the connection, or uses a networking layer that enforces the destination policy at connection time.

This is one reason SSRF defense is more than input validation. The security decision has to survive all the way to the network connection.

Treat redirects as new destination decisions

HTTP redirects are especially easy to overlook. An initial URL can satisfy policy and then respond with a redirect to a different host or address.

If the HTTP client follows redirects automatically, validation of only the first URL does not constrain the final destination.

A simple defensive choice is to disable automatic redirects when the feature does not need them. If redirects are required, process them deliberately:

validate destination
       |
       v
send request without automatic redirect
       |
       +-- final response -> process response
       |
       +-- redirect ------> validate new destination
                                |
                                v
                           next request

Each redirect is another outbound request and should pass the same destination policy. Set a finite redirect limit as well; destination validation does not prevent redirect loops from consuming time and connections.

Do not assume that a redirect to an allowlisted hostname is sufficient if your policy also constrains resolved addresses, schemes, or ports. Reapply the complete policy to the new destination.

Put network restrictions behind the application checks

Application validation is valuable because it can express the feature’s intent. Network controls provide a separate boundary if application logic is bypassed or implemented incorrectly.

A dedicated URL-fetching component can run with outbound access limited to the networks and services it actually needs. Depending on the environment, that boundary might be enforced by an egress proxy, firewall rules, network policy, or another controlled outbound gateway.

The principle is the same: the process should not automatically inherit broad network reachability merely because the host or cluster has it.

For a feature with a fixed partner allowlist, application and network policy can be quite narrow. For a public link-preview service that must fetch arbitrary internet pages, the network layer becomes more important because a hostname allowlist is not practical. In that case, isolate the fetcher from sensitive internal networks and enforce destination restrictions at the outbound boundary as well as in application logic.

This is defense in depth rather than a substitute for correct URL handling. A permissive egress policy does not validate redirects, and perfect URL parsing does not compensate for unnecessary access to sensitive networks.

Bound what an allowed request can consume

Destination control answers where the server may connect. It does not answer how much work an allowed destination may cause.

An external server can respond slowly, stream a large body, or keep connections occupied. A practical fetcher therefore needs resource limits appropriate to its job: connection and response timeouts, a maximum response size, bounded concurrency, and restrictions on response types when the feature only understands a small set.

For example, a link-preview worker usually needs enough of a document to extract metadata. It does not need to download an unlimited response into memory before deciding whether it is useful.

These limits reduce resource-exhaustion risk and make failures predictable. They are complementary to SSRF controls: a request can be sent to an allowed public destination and still be operationally harmful if the fetcher has no bounds.

Keep credentials and privileged headers out of generic fetches

A generic fetcher should not silently attach credentials intended for another trust domain.

Suppose the application normally sends an authorization header when talking to an internal API. Reusing that configured HTTP client for user-directed requests risks sending privileged credentials to a destination chosen by the requester. Similar problems can arise with client certificates, cookies, or custom authentication headers.

Use separate clients or explicit request construction so that generic outbound fetches start without ambient application credentials. Add authentication only for destinations where the application deliberately intends to authenticate.

This is a useful general rule beyond SSRF: authority should follow the destination and purpose of a request, not merely the process that happens to send it.

Common fixes that leave the capability too broad

Several controls help in narrow circumstances but are incomplete when used alone.

Checking only the URL scheme blocks unrelated protocols but still permits requests to unintended HTTP destinations. Checking only the hostname ignores the address selected by DNS. Checking only a resolved address can fail if the client resolves the name again before connecting. Checking only the first request ignores redirects. Blocking a short list of internal IP ranges assumes that the denylist captures every destination that matters in the application’s real network.

Another mistake is validating a URL and then handing the original unvalidated string to a different component whose parser interprets it differently. Keep parsing, policy decisions, and connection behavior aligned. If multiple libraries participate, test the boundary cases that matter to your policy rather than assuming they normalize URLs identically.

Finally, do not expose detailed connection failures unnecessarily. Distinguishing “connection refused on internal host” from “destination rejected” can reveal information that the caller does not need. Operational logs may retain useful diagnostic detail, but client-facing errors can remain consistent.

Verify the policy as a network property

Tests should demonstrate not only that suspicious strings are rejected, but that forbidden connections cannot occur.

Build cases around the policy boundaries. Confirm that approved destinations work and that disallowed schemes, hosts, ports, and address classes fail before a request is sent. Test both IPv4 and IPv6 where the environment supports them. Confirm that redirects to forbidden destinations are rejected. Verify that the fetcher does not attach unrelated credentials and that response-size and timeout limits actually stop work.

For network-layer controls, test from the same runtime identity and environment used in production-like deployments. A unit test of a URL validator cannot prove that an egress rule blocks a connection.

Logging should record enough to investigate rejected outbound requests without turning logs into a store for secrets. Useful fields can include the feature initiating the fetch, the normalized destination, the policy decision, and a coarse rejection reason. Avoid recording embedded URL credentials or sensitive query values unless there is a specific protected operational need.

Choose the design from the feature’s real requirement

There are two common cases, and they deserve different designs.

When the application talks to a known set of services, keep the capability narrow: map trusted identifiers to configured destinations, allowlist the required hosts and ports, validate connection destinations, and restrict egress accordingly. This is simpler to reason about and easier to test.

When arbitrary public URLs are the feature, such as a general link-preview service, the destination set cannot be reduced to a short hostname list. Treat the fetcher as a deliberately exposed network component. Isolate it from sensitive networks, constrain schemes and ports, enforce address policy at connection time, revalidate redirects, remove ambient credentials, and bound resource use.

Neither design makes fetched content trustworthy. Parsers and renderers that consume remote content still need their own security controls. The SSRF boundary has one focused job: keep requester influence over outbound networking inside the capability the product intentionally provides.

Make the outbound capability explicit

The safest server-side fetch is one whose reachability matches the feature’s actual purpose. Start by removing arbitrary destinations when they are unnecessary. Where flexible URLs are required, carry the destination policy from parsing through DNS resolution, redirects, and the final connection, then reinforce it with network isolation and egress controls.

A useful review question is: if a requester controls this value, exactly which network connections can they cause our system to make? If the answer is broader than the feature requires, tighten the capability before adding more URL filters.