SSRF, cloud metadata, and workload identity

What it is

Server-Side Request Forgery is an attacker making your server issue a request of their choosing. The application fetches a URL the attacker controls, and because the request originates from inside your network, it reaches things the attacker cannot reach directly.

Attacker -> your server: "fetch https://internal-admin.svc/delete-all"
Your server (trusted, inside the network) -> internal-admin: executes it

Workload identity is the defence against the worst SSRF target and a security improvement in its own right: giving a workload a cryptographically-verifiable identity so it needs no static credentials that an SSRF or an RCE could steal.

The two are one topic because the highest-value SSRF target is the cloud metadata endpoint, and workload identity is what removes the credentials that endpoint hands out.

SSRF to 169.254.169.254 (the metadata endpoint):
  -> returns the node's IAM role credentials
  -> the attacker now has the node's cloud permissions
  -> which, without workload identity, are the UNION of every workload
     on that node

What this is confused with: SSRF being an input-validation bug. Blocklisting 169.254.169.254 is necessary and insufficient, because the attacker can reach it through DNS rebinding, redirects, alternative encodings and IPv6. SSRF defence is architectural, and workload identity is the part that makes a successful SSRF worth less.

The problem it solves

Any feature that fetches a URL is a potential SSRF vector, and there are many more than teams enumerate:

Obvious:    webhook delivery, URL preview/unfurling, "import from URL",
            PDF generation from a URL, image proxying
Less obvious: an XML parser resolving external entities (XXE-to-SSRF),
            a PDF library fetching remote images, an SVG renderer,
            a link-checker, a health-check that pings a user-supplied URL,
            OAuth/OIDC discovery fetching a user-supplied issuer's config

The OIDC discovery case is subtle and real: an application that lets a tenant configure their own identity provider fetches {issuer}/.well-known/openid-configuration, and if {issuer} is attacker-controlled, that is an SSRF with the application's full trust.

The targets a successful SSRF reaches:

- the cloud metadata endpoint (credentials)
- internal services with no authentication ("it's on the internal network")
- internal admin panels
- other tenants' data in a multi-tenant system
- the Kubernetes API server, kubelet, etcd
- databases and caches bound to internal addresses

"It's on the internal network so it doesn't need auth" is the assumption SSRF violates, and it is why zero-trust (see zero trust) treats the network as untrusted: an SSRF turns the attacker into an internal caller.

Mechanics

The metadata endpoint, and IMDSv1 versus IMDSv2

# IMDSv1: a simple GET returns credentials. An SSRF is one request.
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/node-role
-> { "AccessKeyId": "...", "SecretAccessKey": "...", "Token": "..." }

IMDSv2 requires a PUT to get a token first, which most SSRF vectors cannot do (they issue a GET), and it sets a hop limit so the response cannot traverse a container boundary:

# IMDSv2: session-oriented.
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
             -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
     http://169.254.169.254/latest/meta-data/iam/security-credentials/node-role
IMDSv2 defences:
  - requires a PUT (most SSRF vectors do GET only)
  - the token is not a standard header an SSRF sets easily
  - X-Forwarded-For present -> request rejected (blocks proxied SSRF)
  - hop limit of 1 by default -> a containerised app cannot reach it
    unless the hop limit is raised

Enforcing IMDSv2 (HttpTokens: required) closes most SSRF-to-credentials paths, and it is a one-line instance metadata option. It is not the default on older instances, which is why it is worth checking explicitly. GCP and Azure have equivalent metadata-header requirements.

Why blocklists fail

A denylist of 169.254.169.254 is bypassable in at least six ways:

DNS rebinding:      attacker.com resolves to a public IP on first lookup
                    (passes validation) and to 169.254.169.254 on the
                    fetch (TOCTOU between validation and use)
Redirects:          the validated URL returns 302 -> 169.254.169.254
Alternative IPs:    http://169.254.169.254 == http://0xa9fea9fe
                    == http://2852039166 == http://[::ffff:169.254.169.254]
IPv6:               fd00:ec2::254 (AWS IPv6 metadata)
Decimal/octal:      http://0251.0376.0251.0376
Wrapped:            http://169.254.169.254.attacker.com (if you match a prefix)

DNS rebinding is the one blocklists cannot fix, because the validation and the fetch are separate DNS lookups and the attacker controls what each returns. The URL you validated is not the URL you fetched.

The defence that works: an allowlist and a pinned resolver

import socket, ipaddress
from urllib.parse import urlparse

BLOCKED_NETS = [
    ipaddress.ip_network("169.254.0.0/16"),   # link-local, incl. metadata
    ipaddress.ip_network("10.0.0.0/8"),        # RFC 1918
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("127.0.0.0/8"),       # loopback
    ipaddress.ip_network("::1/128"),
    ipaddress.ip_network("fd00::/8"),          # IPv6 ULA
]

def safe_fetch(url, allowed_schemes={"https"}):
    parsed = urlparse(url)
    if parsed.scheme not in allowed_schemes:
        raise SSRFBlocked("scheme")

    # Resolve ONCE, validate the resolved IP, then connect to THAT IP.
    # This closes the DNS-rebinding TOCTOU: we do not resolve twice.
    ip = socket.getaddrinfo(parsed.hostname, parsed.port or 443,
                            proto=socket.IPPROTO_TCP)[0][4][0]
    addr = ipaddress.ip_address(ip)
    if any(addr in net for net in BLOCKED_NETS) or not addr.is_global:
        raise SSRFBlocked(f"resolved to {ip}")

    # Connect to the validated IP, NOT the hostname, and disable redirects.
    return http_get(url, resolve_to=ip, allow_redirects=False, timeout=5)

The two load-bearing decisions: resolve once and connect to the resolved IP (so validation and fetch see the same address), and disable redirects (so a 302 to the metadata endpoint is not followed). not addr.is_global is the catch-all: rather than enumerating bad ranges, reject anything that is not a public address, which covers link-local, private, loopback and reserved in one check.

The strongest version is architectural: route all outbound fetches through an egress proxy that enforces the allowlist, so the application cannot make an arbitrary connection at all. That also gives you one place to audit and log outbound traffic.

Workload identity: no static credentials

A static credential is a secret that can be stolen and used from anywhere. An SSRF that reaches the metadata endpoint, an RCE that reads a config file, a leaked environment variable, a credential in a git history: all of these are stolen-static-credential incidents.

Workload identity replaces the static secret with a cryptographically-verifiable identity the platform issues and rotates:

SPIFFE/SPIRE: every workload gets a SPIFFE ID (spiffe://example.org/ns/prod/sa/payments) delivered as a short-lived X.509 certificate or JWT (an SVID), attested by the platform:

1. SPIRE agent on the node attests the workload:
     - which container image (by digest)?
     - which Kubernetes service account?
     - which node (by cloud instance identity)?
2. Only if the attestation matches a registration entry does the
   workload receive an SVID.
3. The SVID is short-lived (default ~1 hour) and auto-rotated.

The attestation is what makes it not-a-secret: the workload does not present a stored credential, it proves properties about itself (its image digest, its service account, its node) that the platform verifies. A stolen SVID expires in an hour, and it cannot be minted elsewhere because the attestation would fail.

Cloud-native equivalents:

AWS IRSA / EKS Pod Identity:
  the pod's service account is annotated with an IAM role;
  the pod receives a projected, short-lived OIDC token;
  AWS STS exchanges it for temporary role credentials.
  -> the pod never holds a long-lived AWS key.

GCP Workload Identity:
  the KSA is bound to a GSA; the pod gets short-lived tokens.

Azure Workload Identity:
  federated credentials, the KSA trusted by an Entra app.

The key property in all three: the pod holds no long-lived credential, so there is nothing for an SSRF or an RCE to steal that works for more than an hour and works from anywhere.

And it is per-pod, not per-node, which is the improvement over the metadata endpoint even with IMDSv2:

Node IAM role:     every pod on the node has the UNION of what any pod needs
Per-pod identity:  the payments pod has payments permissions, the logging
                   pod has logging permissions, and an SSRF in one does not
                   grant the other's

A worked example: SSRF to full cloud takeover, prevented in layers

A SaaS platform with a "website preview" feature: users paste a URL and the server fetches it to generate a thumbnail. EKS, IMDSv1, node IAM roles.

The finding, in a red-team exercise:

1. Attacker submits preview URL: http://169.254.169.254/latest/meta-data/
                                 iam/security-credentials/
   -> the preview service fetched it (IMDSv1, plain GET)
2. Response: the node's IAM role credentials.
3. The node role had s3:* and dynamodb:* because SOME pod on some node
   needed them, and node roles are shared.
4. The attacker used the credentials from their own machine to read
   every S3 bucket and DynamoDB table in the account.

time from SSRF to data exfiltration: ~4 minutes

Four minutes from a thumbnail feature to the whole account, and every step was a default.

The defence, in the order it was deployed:

Layer 1: fix the SSRF itself.

# The preview fetch, before: requests.get(user_url)
# After: allowlist by resolved IP, no redirects, egress proxy.
def preview_fetch(url):
    validate_scheme(url, {"https"})
    ip = resolve_once(url)
    if not ipaddress.ip_address(ip).is_global:
        raise SSRFBlocked()
    return proxy_get(url, resolve_to=ip, allow_redirects=False)
preview requests to internal addresses:  blocked
DNS-rebinding attempts:                   blocked (resolve-once)
redirect-to-metadata attempts:            blocked (no redirects)

Layer 2: enforce IMDSv2.

# On the node group
metadata_options {
  http_tokens                 = "required"     # IMDSv2 only
  http_put_response_hop_limit = 1              # containers cannot reach it
  http_endpoint               = "enabled"
}
SSRF-to-metadata via GET:  now blocked (requires a PUT)
containerised SSRF:        now blocked (hop limit 1)

Even if the SSRF fix had been imperfect, IMDSv2 with a hop limit of 1 closes the container path, which is the defence-in-depth argument: two independent controls, either of which stops the chain.

Layer 3: per-pod identity, removing the shared node role.

# The preview pod's service account, bound to a MINIMAL IAM role.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: preview-service
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123:role/preview-service-role
# preview-service-role: s3:PutObject on ONE thumbnail bucket, nothing else.
node role permissions:        s3:*, dynamodb:*  (removed)
preview pod permissions:       s3:PutObject on the thumbnails bucket only

The preview service now cannot read any bucket, so even a perfect SSRF to a working metadata endpoint would yield credentials that can write one thumbnail and nothing else.

Layer 4: the audit found the same class elsewhere.

features fetching user-supplied URLs:     11
  of which validated the URL:              2
  of which used an egress proxy:           0
pods using the node IAM role:             41 of 44
pods with a per-pod role:                  3 of 44
node roles with wildcard permissions:      6 of 8
IMDSv1 enabled:                            all node groups

Forty-one of forty-four pods shared the node role, so an SSRF or RCE in any of them yielded the union of everything. Moving to per-pod IRSA roles was the largest piece of work and the largest reduction in blast radius.

Layer 5: SPIFFE for service-to-service, replacing static tokens.

before:  services authenticated to each other with static API keys in
         Kubernetes Secrets. An RCE in any pod could read its own Secret
         and call other services as itself.
after:   SPIRE issues SVIDs; services authenticate with mTLS using
         short-lived certificates tied to their service account and image
         digest. A stolen SVID expires in an hour and cannot be reissued
         elsewhere.
static service credentials in the cluster:  38 -> 0
mean credential lifetime:                    permanent -> ~1 hour

Verified by re-running the exercise:

                              first run       second run
preview SSRF                  succeeded       blocked (allowlist)
   if allowlist bypassed      -               blocked (IMDSv2 + hop limit)
   if metadata reached        node role       preview role: write one bucket
credentials useful from
  attacker's machine          yes (static)    no (SVIDs are short-lived and
                                              attested)
time to account takeover      4 min           not achieved (4-hour exercise)

Four independent layers, each of which alone reduced the impact, and the point is that no single one was trusted to be sufficient: the SSRF fix could have a bypass, IMDSv2 could be misconfigured, and the per-pod role is what made the residual risk small.

Final:

                              before      after
features validating URLs      2/11        11/11 (+ egress proxy)
IMDSv1                        all nodes   disabled (IMDSv2 required)
node role blast radius        s3:*+       per-pod, least privilege
pods on the shared node role  41/44       0/44
static service credentials    38          0 (SPIFFE)
SSRF-to-takeover              4 min       not achieved

The four-minute takeover needed IMDSv1, a shared node role with wildcards, and an unvalidated fetch, and removing any one of the three would have broken the chain. Removing all three, plus per-pod identity and SPIFFE, made a successful SSRF worth writing one thumbnail.

Production evidence

The Capital One breach (2019) was an SSRF against a metadata endpoint (IMDSv1) yielding a role with broad S3 access, exfiltrating 100 million records. It is the canonical real-world instance of exactly this chain, and it is why AWS built IMDSv2 and why the metadata endpoint is the first SSRF target anyone checks.

IMDSv2 was AWS's direct response, and AWS now defaults new instances to IMDSv2 and publishes guidance to enforce HttpTokens: required and a hop limit of 1 for containerised workloads. GCP and Azure require a metadata header (Metadata-Flavor: Google, Metadata: true) for the same reason: a header an SSRF does not set by default.

SPIFFE/SPIRE is a CNCF graduated project, and its threat model is explicit that the goal is eliminating long-lived credentials and the attestation is what distinguishes an SVID from a bearer secret. It is used by Uber, Netflix, Bytedance and others for service identity.

AWS IRSA, GCP Workload Identity and Azure Workload Identity all implement the same pattern: a projected short-lived OIDC token exchanged for cloud credentials, so the pod holds no long-lived key. Their existence as first-party features is the clearest signal that per-pod identity is the intended model rather than node roles.

OWASP added SSRF to the Top 10 in 2021 (A10), reflecting how common it had become as applications increasingly fetch URLs, and the OWASP SSRF prevention cheat sheet is where the allowlist-and-resolve-once guidance is codified.

The debate

Is fixing the SSRF enough, or do you need the other layers? Not enough, and this is the central position. SSRF fixes have bypasses (a new redirect vector, a parser that resolves URLs you did not know about), so the defence is layered: fix the SSRF, enforce IMDSv2 so the metadata path is closed independently, and use per-pod least-privilege identity so a successful SSRF yields little. Each layer assumes the others might fail, which is why the Capital One chain needed three simultaneous defaults.

Blocklist or allowlist for outbound URLs? Allowlist, always, and where an allowlist is impossible (the feature genuinely fetches arbitrary user URLs), reject anything that resolves to a non-global address and route through an egress proxy. Blocklists of specific IPs are bypassable by DNS rebinding, redirects and encoding, and the resolve-once-connect-to-the-IP pattern is what closes the rebinding window.

Is IMDSv2 sufficient on its own? It closes most SSRF-to-credential paths and it is not sufficient, because a hop limit can be misconfigured, an SSRF vector that can issue a PUT exists, and the credentials it protects should not be broad in the first place. IMDSv2 plus per-pod identity is the pair: the first makes the credentials hard to reach, the second makes them not worth much.

Node roles or per-pod identity? Per-pod, without qualification, on any multi-tenant or multi-workload node. A node role is the union of every pod's needs, so a compromise of the least-privileged pod yields the most-privileged pod's access. The operational cost is a role and a service-account annotation per workload, which is small against the blast-radius reduction.

Is SPIFFE worth the complexity? It is real infrastructure (a SPIRE server, agents, a registration process), and the value is eliminating long-lived service credentials, which are the thing an RCE steals. For a large service mesh where credential rotation and theft are real concerns, yes; for a handful of services, the cloud-native workload identity features (IRSA, GCP WI) give most of the benefit with less to operate.

What is the single highest-value change? Enforcing IMDSv2 and removing wildcard node roles, because together they turn the metadata endpoint from "the keys to the account" into "a short-lived credential for one pod's narrow job." Both are configuration rather than code.

Follow-up Q&A

"What is SSRF and why is the metadata endpoint the first target?"

SSRF is making the server issue a request the attacker chooses, so the request originates from inside the trusted network and reaches things the attacker cannot reach directly. The metadata endpoint (169.254.169.254) is the first target because it returns the node's IAM role credentials, and without per-pod identity those are the union of every workload on the node. The Capital One breach was exactly this: SSRF to IMDSv1, a role with broad S3 access, 100 million records.

"Why don't blocklists work?"

Because the attacker controls what a hostname resolves to, and the validation and the fetch are separate lookups: DNS rebinding returns a public IP when you validate and 169.254.169.254 when you fetch. Plus redirects, alternative encodings (decimal, octal, hex, IPv6), and wrapped hostnames. The defence is to resolve once, validate the resolved IP is a global address, connect to that IP rather than the hostname, and disable redirects.

"What is IMDSv2 and what does it stop?"

A session-oriented metadata service: you PUT to get a token, then send it as a header on GET requests. It stops SSRF because most vectors issue a GET only and cannot do the PUT, it rejects requests carrying X-Forwarded-For (blocking proxied SSRF), and its hop limit of 1 means a containerised app cannot reach it unless the limit is raised. Enforcing HttpTokens: required is a one-line instance option and closes most SSRF-to-credential paths.

"What is workload identity and why does it matter for SSRF?"

It gives a workload a cryptographically-verifiable, short-lived, platform-issued identity instead of a static credential, so there is nothing for an SSRF or RCE to steal that works for long or works from elsewhere. SPIFFE/SPIRE attests the workload (image digest, service account, node) and issues an hour-long SVID; IRSA and GCP Workload Identity exchange a projected OIDC token for temporary cloud credentials. Critically it is per-pod, so an SSRF in one workload does not grant another's access, which a shared node role does.

"How do the two connect?"

The worst SSRF target is the metadata endpoint because it hands out credentials, and workload identity removes the broad node-role credentials it would hand out, replacing them with per-pod, short-lived, attested identity. So the layered defence is: fix the SSRF, enforce IMDSv2 so the metadata path is independently closed, and use per-pod identity so a successful SSRF yields a narrow, short-lived credential. Each layer assumes the others might fail.

"What would you enforce first?"

IMDSv2 (HttpTokens: required, hop limit 1) and removing wildcard node roles, because together they turn the metadata endpoint from account-takeover into a narrow short-lived credential, and both are configuration rather than code. Then the SSRF fix on every feature that fetches a URL, using resolve-once-and-allowlist, and an egress proxy so the allowlist is enforced in one place.

Common misconceptions

"SSRF is an input-validation bug." Validation helps and is bypassable by DNS rebinding, redirects and encoding. The defence is architectural: resolve-once, allowlist by resolved IP, egress proxy, and reduce what a successful SSRF can reach.

"Blocking the metadata IP is enough." It is reachable through rebinding, redirects, alternative encodings and IPv6. Reject any non-global resolved address instead.

"IMDSv2 is on by default." New instances default to it; existing ones and many node groups do not. HttpTokens: required must be set explicitly.

"A node IAM role is fine." It is the union of every pod's permissions, so a compromise of the least-privileged pod yields the most-privileged pod's access. Per-pod identity is the fix.

"Workload identity is just credential rotation." It is the elimination of static credentials via attestation: the workload proves properties about itself rather than presenting a stored secret, so a stolen identity expires quickly and cannot be reissued elsewhere.

Interview delivery note

Say this verbatim: "The Capital One chain was SSRF to the metadata endpoint on IMDSv1, a node role with broad S3 access, and 100 million records in minutes. The defence is three independent layers: fix the SSRF with resolve-once-and-allowlist, enforce IMDSv2 so the metadata path is closed regardless, and use per-pod identity so a successful SSRF yields a narrow short-lived credential. Each layer assumes the others might fail." The canonical incident, the chain, and the layered defence.

The senior-versor-staff separator is connecting SSRF to workload identity. A senior engineer fixes the SSRF and enforces IMDSv2. A staff engineer notes that the reason the metadata endpoint is catastrophic is the shared node role, that per-pod identity turns account-takeover into one-pod's-narrow-access, and that the same reasoning eliminates static service credentials with SPIFFE. Reducing what a successful attack is worth, rather than only preventing the attack, is the staff-level move.

The second signal is resolve-once. Explaining that DNS rebinding works because validation and fetch are separate lookups, and that connecting to the validated IP rather than re-resolving the hostname closes the window, shows you understand why blocklists fail rather than just that they do.

Further reading

  • The Capital One breach postmortems and AWS's IMDSv2 announcement, for the canonical SSRF-to- metadata chain and the response.
  • OWASP's SSRF Prevention Cheat Sheet, for the allowlist and resolve-once guidance.
  • The SPIFFE and SPIRE documentation, particularly the attestation model and SVID lifecycle.
  • AWS IRSA / EKS Pod Identity and GCP Workload Identity documentation, for the projected-token exchange that removes long-lived cloud credentials.