Instance Metadata Service

The instance metadata service is the link-local endpoint a cloud instance queries for its own metadata and role credentials. How IMDSv1 and IMDSv2 differ.

The instance metadata service (IMDS) is a local HTTP endpoint that a cloud virtual machine queries to read facts about itself: its instance id, its instance type, the region and availability zone it runs in, its network addresses, and the temporary credentials of any identity role attached to it. It answers on a link-local address that is reachable only from inside that one instance, which is why no hostname, no credential, and no network route is needed to call it.

Every major cloud exposes one, and every cloud SDK reads it. A build agent that never mentions an access key still ends up authenticated because the SDK walked its default credential chain down to the metadata endpoint and found a role there.

Definition

The metadata service listens on the link-local IPv4 address 169.254.169.254 on port 80, with an IPv6 form of fd00:ec2::254 on AWS. Link-local means the address is never routed: a packet sent to it is answered by the hypervisor on the host running that instance, so the same URL returns different data on every machine and returns nothing at all from outside.

Two things live behind that address, and they carry very different risk.

The first is descriptive metadata. Instance id, instance type, placement, AMI or image id, hostname, private and public IPv4 addresses, and any user data script passed at launch. Reading it tells a caller where it is running.

The second is credentials. When a role is attached to the instance, the service issues short-lived credentials for that role on request. On AWS those live under /latest/meta-data/iam/security-credentials/<role-name> and return an access key id, a secret access key, a session token, and an expiry timestamp as JSON. The SDK refreshes them before the expiry without the workload doing anything.

That second capability is what makes the endpoint interesting to an attacker. Any code path on the instance that can be tricked into fetching an attacker-supplied URL, the class of bug called server-side request forgery, can be pointed at 169.254.169.254 and made to return role credentials in the HTTP response body.

The two request protocols

IMDSv1 is a request and response protocol. A plain GET to the metadata path returns the value. Nothing distinguishes a fetch made by the workload from a fetch made by a forged request that the workload was tricked into issuing.

IMDSv2 is session oriented. The caller first sends a PUT to /latest/api/token with a header naming how long the session should last, receives an opaque token in the response body, and then attaches that token as a header on every metadata GET. Three properties follow from that shape (AWS instance metadata service documentation, checked on 2026-08-13):

PropertyIMDSv1IMDSv2
First requestGET /latest/meta-data/...PUT /latest/api/token
Auth on data requestsNoneX-aws-ec2-metadata-token header
Session lifetimeNot applicable1 to 21600 seconds, set by the caller
X-Forwarded-For on the token requestNot applicableRejected
Default response hop limitInstance default1
Reachable by a forged plain GETYesNo

The PUT requirement blocks the common forgery shape, because a URL-fetching bug usually issues a GET and cannot set request headers. Rejecting token requests that carry X-Forwarded-For blocks the reverse-proxy shape, where a misconfigured proxy on the instance relays an outside request inward. The hop limit is an IP time-to-live on the token response: at the default of 1, the response dies at the instance boundary, so a container on a bridge network or a downstream host never receives a usable token.

An instance can be set to accept both protocols or to require IMDSv2, in which case unauthenticated GET requests are refused. Requiring the token version is the setting that closes the forgery path; supporting it while still answering v1 does not.

The same idea on other clouds

The pattern is not unique to one provider. Azure serves its Instance Metadata Service at the same 169.254.169.254 address under /metadata/instance, and rejects any request that omits the Metadata: true header. Google Cloud serves its metadata server at metadata.google.internal and requires a Metadata-Flavor: Google header. Both header requirements exist for the reason IMDSv2 exists: a forged plain GET cannot set them.

Example

A GitHub Actions job running on a self-hosted runner in a cloud account inherits whatever role is attached to that instance. This job reads the region and the attached role name from the metadata service using the token protocol, then lets the AWS CLI resolve credentials through the same endpoint.

name: build
on:
  push:
    branches: [main]

jobs:
  package:
    runs-on: [self-hosted, linux, x64]
    steps:
      - uses: actions/checkout@v4

      - name: Read instance metadata
        run: |
          TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" \
            -H "X-aws-ec2-metadata-token-ttl-seconds: 300")

          curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \
            "http://169.254.169.254/latest/meta-data/placement/region"

          curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \
            "http://169.254.169.254/latest/meta-data/iam/security-credentials/"

      - name: Push the image
        run: |
          aws ecr get-login-password --region us-east-1 \
            | docker login --username AWS --password-stdin "$REGISTRY"
          docker build -t "$REGISTRY/app:$GITHUB_SHA" .
          docker push "$REGISTRY/app:$GITHUB_SHA"
        env:
          REGISTRY: ${{ vars.ECR_REGISTRY }}

The same job written against IMDSv1 drops the PUT and the header:

      - name: Read instance metadata (v1 shape)
        run: |
          curl -sS "http://169.254.169.254/latest/meta-data/placement/region"

On an instance that requires IMDSv2, that second form returns HTTP 401 and the step fails. The aws ecr get-login-password step in the first job keeps working either way, because current AWS SDK and CLI releases request a token first and fall back only when the instance still allows v1.

Two failure modes show up repeatedly in GitHub Actions when the token version is required:

SymptomCauseFix
Every metadata curl returns 401Step issues a plain GET with no tokenIssue the PUT first and pass the token header
Steps work directly but fail inside a container: jobResponse hop limit of 1 stops the token at the instance boundaryRaise the hop limit to 2, or pass credentials into the container explicitly

The second row is worth a workflow-level note. A job that sets jobs.<job_id>.container runs its steps inside a Docker container on the runner, which adds a network hop between the step and the host. Nothing about the workflow file changes; the same commands that succeeded as host steps start timing out or returning 401 inside the container.

FAQ

What is the difference between IMDSv1 and IMDSv2?

IMDSv1 answers a plain GET to the link-local address with no authentication step. IMDSv2 is session oriented: the caller first sends a PUT to /latest/api/token with a time-to-live header, receives a session token, and puts that token in a header on every later GET. A request that cannot issue the PUT and read the response headers, which is the shape most server-side request forgery bugs take, cannot reach the metadata.

Why does a container on the instance fail to read instance metadata?

IMDSv2 token responses carry an IP time-to-live, called the response hop limit, that defaults to 1. A packet leaving the instance for a container on a bridge network crosses one hop, so the token response expires before it arrives. Raising the hop limit to 2 lets containers reach the service, and leaving it at 1 keeps metadata inside the instance.

What does the metadata service return that a build might need?

The instance identity fields (instance id, instance type, region, availability zone, private and public IPv4 addresses) and, when a role is attached to the instance, temporary credentials under /latest/meta-data/iam/security-credentials/. Cloud SDKs read that credential path automatically as the last step of their default credential chain.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.