Skip to content

    System Design

    SSRF and the OWASP API Security Top 10: Defending Your Own API

    Read the OWASP API Security Top 10 (2023) as a design checklist, then go deep on server-side request forgery: how it works, and the layers that stop it.

    By Published 13 min read

    Share

    Standard lessonintermediateBackend System Design

    Before this: Input Validation and Injection Prevention (coming soon)

    What you'll learn: By the end of this lesson you will be able to name the ten risks in the OWASP API Security Top 10 (2023 edition) and map each to a design decision, explain exactly how server-side request forgery (SSRF) turns your server into someone else's tool, describe the layered defence the OWASP guidance recommends (allow-lists, checking the resolved address, no redirects, no raw responses, network isolation), state what the cloud metadata services document about themselves, and review an "import from URL" feature for the mistakes that bypass the usual fixes.

    A hotel receptionist is helpful. A guest asks her to ring room 412 and read back what the occupant says. She does. Then someone asks her to ring the manager's private line and read out the safe's code from the sticky note. She dials, because she is allowed to call that number from the reception phone. The guest never had access to that line. The receptionist did, and she lent it out.

    An API that fetches a web address on a caller's behalf is that receptionist. The caller supplies the address, the server makes the request from inside your network, and the server's network position, not the caller's, decides what answers. That is SSRF.

    This lesson builds on Security for Distributed Systems (threat modelling, authorisation, layering) and on ShopStream Stage 10, which gives SSRF one paragraph. Here SSRF gets the full treatment, after a map of the whole OWASP API list. The lessons on input validation and on secrets management are planned and have no page yet.

    Verified against the OWASP API Security Top 10 2023 pages, the OWASP SSRF Prevention Cheat Sheet, the AWS EC2 and Google Compute Engine documentation on their metadata services, and the Kubernetes NetworkPolicy documentation, on 2026-11-08.

    The core idea

    An SSRF flaw exists whenever your server makes a network request whose destination an outsider influences, and the server's own network reach is larger than the outsider's. The OWASP API page defines it as an API fetching a remote resource without validating the user-supplied URL, which lets an attacker coerce the application to send a crafted request to an unexpected destination, even behind a firewall or VPN.

    Three examples run through the lesson: a minimal example (a Java check on a resolved address), a realistic example (ShopStream's import of a product photo from a web address) and a failure example (a redirect that sends a request that passed every check to the cloud metadata address).

    The OWASP API Security Top 10 (2023) as a map

    The 2023 edition has ten entries. The table pairs each with the design question it asks.

    ID and title (2023) The design question it asks
    API1 Broken Object Level Authorization Does every endpoint that takes an object id check that this caller may act on this object?
    API2 Broken Authentication Can credentials be stuffed or brute-forced? Are unsigned tokens accepted?
    API3 Broken Object Property Level Authorization May this caller read or write each property, not just the object?
    API4 Unrestricted Resource Consumption Is there a limit on size, count, time and spend per request? See Rate Limiting.
    API5 Broken Function Level Authorization Can an ordinary user reach an admin function by changing the method or path?
    API6 Unrestricted Access to Sensitive Business Flows Which flows (buying, booking, referral credit) hurt the business if automated?
    API7 Server Side Request Forgery Does the server fetch addresses an outsider chooses? This lesson.
    API8 Security Misconfiguration Is hardening repeatable, are errors schema-limited, is TLS on everywhere?
    API9 Improper Inventory Management Do you know every API host, version and third-party flow, and the retirement plan for each?
    API10 Unsafe Consumption of APIs Do you trust what other APIs return, or follow their redirects blindly?

    Two patterns stand out. Authorisation fails at three levels, object (API1), property (API3) and function (API5), so a review that only asks "is the user logged in?" misses all three. And API7 and API10 are two sides of one coin: SSRF is your server tricked into calling something bad, unsafe consumption is your server trusting something it called. Both come from treating outbound traffic as safe because it leaves the building.

    OWASP adds that comparing the session's user id with the request's id "isn't a sufficient solution" for API1; use policy-based checks on every function that takes client input to reach a record, and tests that block a deploy when authorisation breaks.

    How SSRF works

    Features that take a URL are the usual doors; OWASP lists webhooks, fetching a file from a URL, custom single sign-on and URL previews. The server has no coding bug in the usual sense: it does what the feature says, but nothing limits where.

    sequenceDiagram accTitle: A naive URL-fetching endpoint abused for SSRF accDescr: An attacker submits an internal address to a public endpoint. The server fetches it from inside the private network and returns the response, which contains credentials from the metadata service. participant A as Attacker participant API as Public API server participant M as Metadata service (internal) A->>API: POST /import { "url": "http://169.254.169.254/..." } Note over API: no check on the destination API->>M: GET the internal address M-->>API: 200 with role credentials API-->>A: returns the fetched content Note over A: the attacker now holds credentials

    Figure 1. The naive flow. How to read this: the attacker never connects to the metadata service. The API server does, from a position the attacker cannot reach, and then hands back the answer.

    OWASP's two example scenarios have this shape. A profile-picture feature accepts an image URL; an attacker submits internal addresses and uses response timing to learn which ports are open. A webhook setup that sends a test request to a user-supplied URL and shows the response, pointed at a cloud metadata service, displays credentials. The second is worse: the response is shown, so no side channel is needed.

    What the cloud metadata services say about themselves

    Metadata services are the classic prize: a fixed address that answers anything on the machine. Only what the providers document is stated here.

    • Amazon EC2: the instance metadata service (IMDS) is at 169.254.169.254 (IPv4) and [fd00:ec2::254] (IPv6, on Nitro instances in supported subnets). IMDSv1 is plain request and response. IMDSv2 is session-oriented: a PUT returns a token that must accompany later GET requests. By default either version, or both, can be used; an instance can be set to accept only IMDSv2. With tokens required, a request without a valid token gets 401. PUT requests carrying X-Forwarded-For are rejected, and the PUT response has an IP-level hop limit of 1 by default.
    • Google Compute Engine: the metadata server is at metadata.google.internal, at 169.254.169.254 and, on IPv6-only VMs, at fd20:ce::254. Instances reach it without extra authorisation, and any process on the VM that can query the URL can read all its values, including certificates and keys.

    Two lessons follow. A fetcher that blocks 169.254.169.254 but not the IPv6 forms leaves a door open. And IMDSv2 raises the bar, because a feature that can only issue GET requests cannot first make the PUT it requires (an inference from the documented flow). The OWASP cheat sheet calls it "an additional defense-in-depth mechanism" that mitigates some SSRF instances, not a fix.

    Defence in layers

    The OWASP SSRF Prevention Cheat Sheet splits the problem in two. Case 1: the application only needs to reach known, trusted applications, so an allow-list is feasible and should be used. Case 2: the application must fetch from arbitrary external hosts (a link preview, a webhook to a customer's server). The destinations are unknown and changing, so you must block internal ranges instead, and the cheat sheet says deny-lists are "bypass-prone". First decide which case you are in, and move toward case 1 whenever you can.

    flowchart TD accTitle: Choosing an SSRF defence strategy accDescr: Start by asking whether the feature can avoid taking a URL. If the destinations are known, use an allow-list. If they are arbitrary, fetch through an isolated worker with resolved-address checks, no redirects and no raw responses. Q1{"Can the feature take an id, not a URL?"} -->|yes| A1["Build the request from fixed scheme, host, port and path"] Q1 -->|no| Q2{"Are the destinations a known set?"} Q2 -->|yes| A2["Allow-list hosts, schemes, ports and media types"] Q2 -->|"no, arbitrary hosts"| A3["Isolated fetch worker with egress rules"] A3 --> A4["Resolve, check the address, connect to that address"] A4 --> A5["No redirects, size and time limits, never return the raw response"]

    Figure 2. Picking the strategy. How to read this: each "yes" removes risk before any filter is needed. Only the bottom branch needs the heavy controls.

    The layers, in the order a request meets them:

    1. Avoid complete URLs. The cheat sheet says they are hard to validate. Accept an id or a hostname from a fixed list and build the request from fixed scheme, port and path.
    2. Allow-list origins, schemes, ports and media types (API7 prevention), using a well-tested URL parser. Parsers can read different hosts from one string (the cheat sheet's example is http://example.com\@evil.com); treat parser disagreement as a rejection.
    3. Resolve, check the address, connect to that address. Allow-listing a domain does not stop DNS rebinding: a name can resolve to a public address at check time and a private one a moment later. Validate the resolved IPs and connect only to them, on retries and fallbacks too.
    4. Disable redirects, or re-run every check on every hop. Redirects can bypass input validation.
    5. Never send raw responses to clients. Return what the feature needs (a stored, re-encoded image), and add size and time limits (API4).
    6. Isolate the fetcher on the network. API7 says to isolate the fetching mechanism; the cheat sheet recommends firewall rules that allow only legitimate flows, alongside application checks.
    7. Harden what it could reach. Require IMDSv2 on EC2, and give the fetcher a role that can do almost nothing.

    Minimal example: judging a resolved address

    Layer 3 needs a function that decides whether an address is acceptable. Java's InetAddress helpers (isLoopbackAddress, isSiteLocalAddress and others) do not cover everything. Run on JDK 26.0.1, fc00::1 and fd20:ce::254 (the IPv6 unique-local range, which contains Google's documented IPv6 metadata address) and 100.64.1.1 (shared address space) all report siteLocal=false, linkLocal=false and loopback=false, so the check below adds them. A literal address is parsed, not looked up, so the demo needs no network.

    static boolean isPublic(InetAddress a) {
        if (a.isAnyLocalAddress() || a.isLoopbackAddress() || a.isLinkLocalAddress()
                || a.isSiteLocalAddress() || a.isMulticastAddress()) {
            return false;
        }
        byte[] b = a.getAddress();
        if (b.length == 16) {                       // IPv6: also block unique local fc00::/7
            return (b[0] & 0xFE) != 0xFC;
        }
        int first = b[0] & 0xFF;
        int second = b[1] & 0xFF;
        if (first == 100 && second >= 64 && second <= 127) return false;   // 100.64.0.0/10
        return true;
    }

    Looping over a list of literals with InetAddress.getByName prints this on JDK 26.0.1:

    93.184.216.34              IPv4  ALLOW
    127.0.0.1                  IPv4  BLOCK
    10.0.3.7                   IPv4  BLOCK
    172.16.0.9                 IPv4  BLOCK
    192.168.1.20               IPv4  BLOCK
    169.254.169.254            IPv4  BLOCK
    100.64.1.1                 IPv4  BLOCK
    0.0.0.0                    IPv4  BLOCK
    ::1                        IPv6  BLOCK
    fe80::1                    IPv6  BLOCK
    fc00::1                    IPv6  BLOCK
    fd20:ce::254               IPv6  BLOCK
    ::ffff:169.254.169.254     IPv4  BLOCK
    2606:4700:4700::1111       IPv6  ALLOW

    The IPv4-mapped form ::ffff:169.254.169.254 was parsed to an IPv4 address on this JDK, so the link-local check caught it. Other libraries may treat it differently, which is why the check belongs on the address the socket will actually use. It is one layer: it does nothing for a redirect, a second lookup, or a service on a public address.

    The code above shows the check only. The flow around it, in pseudocode:

    Pseudocode.
    fetch(url):
      parsed = parse(url) with a vetted parser; reject on any disagreement
      require scheme in {https}, port in {443}, host in allowedHosts (case 1) or any (case 2)
      addrs = resolve(parsed.host)
      require every address in addrs satisfies isPublic
      connect to one of addrs (not to the name), send Host/SNI for parsed.host
      do not follow redirects; cap bytes and seconds
      return only what the caller needs (store or re-encode), never the raw response

    Trade-offs

    Choice Gain Cost Wrong when
    Remove the URL feature The class of bugs goes away Less convenient The feature is the product (webhooks, previews)
    Allow-list of hosts Strong and simple (case 1) A list to maintain Customers choose arbitrary hosts
    Resolve-then-check (case 2) Works for arbitrary hosts Subtle; one miss is a hole It is the only defence
    Isolated fetch worker A bypass lands on a box with no reach A component and firewall rules to own A tiny internal tool with no sensitive neighbours
    IMDSv2 required Raises the bar on EC2 Old SDKs must support it It replaces fixing the fetcher

    In practice: letting stores import a photo from a web address

    This is the realistic example. It is an educational scenario on ShopStream and describes no real company.

    This is a realistic scenario, not a case history.

    The situation

    Store owners add product photos in the dashboard. The media pipeline of Stage 7 takes an upload, resizes it and stores it. Owners ask to paste a photo's web address and let ShopStream fetch it. The feature would live in the Catalogue service, which runs in the same private network as Orders and Payments and holds a cloud role that can write the media bucket.

    Starting architecture

    Proposed import feature: the Catalogue service fetches any address a store owner pastesStore owner to Catalogue service (photo URL); Catalogue service to Owner's web server (intended fetch); Catalogue service to Cloud metadata (reachable too); Catalogue service to Payments service (reachable too); Catalogue service to Media bucketPRIVATE NETWORKStore ownerStore ownerCatalogue service: fetches the URLCatalogueservicefetches the URLOwner's web server: public internetOwner's webserverpublic internetCloud metadata: link-local addressCloud metadatalink-localaddressPayments service: private networkPaymentsserviceprivate networkMedia bucketMedia bucketphoto URLintended fetchreachable tooreachable too

    Figure 3. Why the proposed design is risky. How to read this: the red dashed arrows are requests the feature was never meant to make but the network allows. Whatever the service can reach, a crafted URL can reach.

    Options considered

    Option Behaviour Cost Risk
    A. Drop the feature Uploads only None Owners keep asking
    B. Dedicated fetch worker Own subnet and egress rules, scheme and address checks, no redirects, no raw responses A new deployable One more component to run
    C. Fetch inside Catalogue with a filter An isPublic-style function in the existing service Little work A bypass lands in a service with a broad role and private reach

    The decision

    Option B, in the shape of case 2 (owners' hosts are arbitrary). The worker accepts only https on port 443, resolves the host, requires every returned address to pass the public-address check, connects to that address, follows no redirects, and caps body size and total time (illustrative limits). It stores the bytes in a quarantine prefix, confirms they decode as an accepted image type, re-encodes them, and only then passes them on; the caller never sees the remote response. Its role can write only the quarantine prefix, and its subnet denies private and link-local ranges. Kubernetes pods are non-isolated by default (all outbound connections allowed), so the cluster side of that rule needs a NetworkPolicy and a network plugin that enforces it (Kubernetes documentation).

    Rollout and verification

    Ship behind a flag with the checks in log-only mode for one release, and review the would-be blocks: a legitimate owner whose photo host redirects to a CDN will appear. Then enforce for a few stores, then all. Test with loopback, private, link-local, IPv6 and mapped addresses, a name that resolves to a private address, and a redirect to a private address; each must fail with no bytes returned. Confirm from inside the worker's subnet that the metadata address and Payments are unreachable, and that EC2 instances require IMDSv2.

    The 2 a.m. failure

    An alert fires: "fetch worker blocked more than 100 requests to non-public addresses in 10 minutes" (an illustrative threshold). One store account is submitting a rapid series of internal addresses and ports.

    sequenceDiagram accTitle: A redirect that passes the host check accDescr: The attacker submits a URL on an allowed public host that answers with a redirect to the metadata address. If the worker follows redirects without re-checking, it connects to the metadata service. With redirects disabled, the fetch fails. participant A as Attacker store account participant W as Fetch worker participant H as Public host (attacker-controlled) participant M as Metadata address A->>W: import https://photos.example.test/a.jpg W->>H: GET (host and address checks pass) H-->>W: 302 Location: http://169.254.169.254/... alt redirects followed without re-checking W->>M: GET M-->>W: credentials else redirects disabled Note over W: fetch fails, event logged end

    Figure 4. The failure example: the first hop was clean. How to read this: validating only the URL the user typed misses the hop the server follows next. The OWASP API7 advice to disable redirections exists for this reason.

    First three checks: does the volume come from one account, are the targets metadata or private addresses, and did any request succeed (a stored result from a non-public address would be the real incident)? Mitigation: suspend import for that account and confirm from the access log that nothing returned content from a non-public address. A misjudgement costs a legitimate store whose photo host redirects, so suspend per account and tell support why. Longer term: a per-account limit on failed imports, and an alert on the first blocked metadata address, not the hundredth.

    Architect's notes

    • Decide the case first. "Can we take an id instead of a URL?" removes more risk than any filter. Ask it for every feature with "URL", "webhook", "callback" or "import" in its name.
    • Inventory egress like you inventory APIs: list every place the server makes a request with a caller-influenced destination.
    • Alert on blocked requests to link-local addresses. They are an attack signal.

    Common mistakes

    • Validating the host name only. The name can resolve anywhere, and differently on the next lookup. Check the address you connect to.
    • Block-listing 169.254.169.254 and stopping. IPv6 forms, private ranges and alternative encodings stay open; the cheat sheet says deny-lists are bypass-prone.
    • Following redirects, or returning the remote response. The first adds destinations, the second turns a blind request into a leak.
    • Treating IMDSv2 as the fix. It is defence in depth on one provider.
    • Forgetting the other nine. An API immune to SSRF that returns every property of every object is still broken (API3).

    Key takeaways

    • The OWASP API Top 10 (2023) lists ten risks; authorisation fails at object, property and function level (API1, API3, API5).
    • SSRF is your server making a request to a destination an outsider influences, using the server's network position.
    • Decide the case: known destinations (allow-list) or arbitrary ones (block internal ranges, weaker). Avoid taking URLs where you can.
    • Check the resolved address and connect to it, disable redirects, never return raw responses, isolate the fetcher.
    • Metadata services answer any process that can reach them: keep them unreachable, and use IMDSv2 on EC2 as an extra layer.

    Practice exercises

    Exercise 1 (30 minutes): For each of the ten OWASP API entries, name one ShopStream endpoint where it could apply and the one check that would stop it.

    Exercise 2 (2 hours): Write a function that validates a URL string (scheme, port, host) and test it on ten hostile inputs, including credentials in the URL, a decimal-encoded host and a trailing-dot host. Record which it rejects and which it cannot decide without resolving the name.

    Exercise 3 (half a day): Design the fetch worker for outgoing ShopStream webhooks to store-owned POS systems (case 2): zone, egress rules, role, timeouts, alerts, and a figure. List three attacks it still permits and how you would detect each.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments