Find EC2 Instances Without IMDSv2 and Require It

Close-up of a server rack with neatly bundled network cables and green status lights

Photo by Albert Stoynov on Unsplash

To find EC2 instances without IMDSv2, call DescribeInstances in each Region with the filter metadata-options.http-tokens=optional. Those instances still accept IMDSv1. Before you require IMDSv2 with ModifyInstanceMetadataOptions, check the CloudWatch metric MetadataNoToken: if it’s zero, nothing on the instance uses IMDSv1 and the change is safe.

This example is for engineers who’ve been asked to find EC2 instances without IMDSv2, usually by a security review, and then close the gap without breaking the software that runs on them. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that scans every enabled Region, shows how many IMDSv1 calls each instance made in the last 14 days, and changes nothing unless you pass --apply.

It sits alongside the other audits in our AWS SDK v3 security and cost examples, and pairs well with the script to find unencrypted EBS volumes and turn on default encryption, which follows the same report-then-apply pattern for disks.

Why does IMDSv1 matter?

The Instance Metadata Service (IMDS) answers on 169.254.169.254 inside every instance. Among other things, it hands out the temporary credentials of the instance’s IAM role. IMDSv1 answers any plain GET. IMDSv2 first requires a PUT to /latest/api/token, which returns a session token valid for up to six hours, and then only answers requests that carry that token.

That extra step is what stops the classic server-side request forgery (SSRF) attack, where a bug tricks your app into fetching a URL for the attacker. The OWASP SSRF Prevention Cheat Sheet lists the metadata address as a target to block and recommends migrating to IMDSv2 and disabling IMDSv1. IMDSv2 also rejects token PUT requests that carry an X-Forwarded-For header, which catches many misconfigured reverse proxies.

What does the script do?

  1. Lists RegionsDescribeRegions returns the Regions enabled for your account, or you pass --regions=.
  2. Reads the account defaultGetInstanceMetadataDefaults shows whether new launches in that Region already default to IMDSv2 and whether enforcement is on.
  3. Finds instances that accept IMDSv1DescribeInstances with metadata-options.http-tokens=optional, skipping terminated instances and ones with the metadata endpoint disabled.
  4. Counts IMDSv1 callsGetMetricData sums MetadataNoToken per instance over 14 days, up to 500 instances per call.
  5. Requires IMDSv2 only on requestWith --apply, calls ModifyInstanceMetadataOptions with HttpTokens: "required" on instances with zero IMDSv1 calls. --set-default also calls ModifyInstanceMetadataDefaults.

Prerequisites

Which IAM permissions does it need?

The first statement is enough for the report. The second is only for --apply; ModifyInstanceMetadataOptions can be scoped to instance ARNs, while the account-default action can’t. Replace 123456789012 with your account ID.

imdsv2-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportImdsSettings",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "ec2:DescribeInstances",
        "ec2:GetInstanceMetadataDefaults",
        "cloudwatch:GetMetricData"
      ],
      "Resource": "*"
    },
    {
      "Sid": "RequireImdsv2",
      "Effect": "Allow",
      "Action": "ec2:ModifyInstanceMetadataOptions",
      "Resource": "arn:aws:ec2:*:123456789012:instance/*"
    },
    {
      "Sid": "SetAccountDefault",
      "Effect": "Allow",
      "Action": "ec2:ModifyInstanceMetadataDefaults",
      "Resource": "*"
    }
  ]
}

The free IAM policy generator for TypeScript SDK code drafts a policy from the script itself. Keep the two write statements out of any role you only use for audits.

The full script to find EC2 instances without IMDSv2

find-ec2-instances-without-imdsv2.ts

// find-ec2-instances-without-imdsv2.ts
// Lists EC2 instances that still accept IMDSv1 (HttpTokens = optional) in every enabled Region,
// with the number of IMDSv1 calls each one made in the last 14 days (CloudWatch MetadataNoToken).
// Report-only by default. --apply requires IMDSv2 on instances with zero IMDSv1 calls;
// add --set-default to also make IMDSv2 the account default for new launches in each Region.
// Usage: npx tsx find-ec2-instances-without-imdsv2.ts [--regions=us-east-1,eu-west-1] [--apply] [--set-default]
import {
  EC2Client,
  DescribeRegionsCommand,
  GetInstanceMetadataDefaultsCommand,
  ModifyInstanceMetadataDefaultsCommand,
  ModifyInstanceMetadataOptionsCommand,
  paginateDescribeInstances,
} from "@aws-sdk/client-ec2";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

const args = process.argv.slice(2);
const apply = args.includes("--apply");
const setDefault = args.includes("--set-default");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1]
  ?? (args.includes("--regions") ? args[args.indexOf("--regions") + 1] : undefined);
const LOOKBACK_DAYS = 14;

interface Row { Region: string; Instance: string; Name: string; State: string; HopLimit: number; IMDSv1Calls: number; Verdict: string }

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({})); // enabled Regions only
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

// Sum of MetadataNoToken per instance over the lookback window. GetMetricData takes up to 500 queries per call.
async function imdsv1Calls(cw: CloudWatchClient, ids: string[]): Promise<Map<string, number>> {
  const totals = new Map<string, number>();
  const end = new Date();
  const start = new Date(end.getTime() - LOOKBACK_DAYS * 86_400_000);
  for (let i = 0; i < ids.length; i += 500) {
    const batch = ids.slice(i, i + 500);
    const queries: MetricDataQuery[] = batch.map((id, n) => ({
      Id: `m${n}`,
      MetricStat: {
        Metric: { Namespace: "AWS/EC2", MetricName: "MetadataNoToken", Dimensions: [{ Name: "InstanceId", Value: id }] },
        Period: 86_400,
        Stat: "Sum",
      },
    }));
    let NextToken: string | undefined;
    do {
      const res = await cw.send(new GetMetricDataCommand({ MetricDataQueries: queries, StartTime: start, EndTime: end, NextToken }));
      for (const r of res.MetricDataResults ?? []) {
        const id = batch[Number((r.Id ?? "m0").slice(1))];
        const sum = (r.Values ?? []).reduce((a, b) => a + b, 0);
        totals.set(id, (totals.get(id) ?? 0) + sum);
      }
      NextToken = res.NextToken;
    } while (NextToken);
  }
  return totals;
}

async function scanRegion(region: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  const defaults = (await ec2.send(new GetInstanceMetadataDefaultsCommand({}))).AccountLevel;
  console.log(`${region}: account default HttpTokens=${defaults?.HttpTokens ?? "no-preference"}, enforced=${defaults?.HttpTokensEnforced ?? "no-preference"}`);

  const found: { id: string; name: string; state: string; hop: number }[] = [];
  for await (const page of paginateDescribeInstances({ client: ec2 }, {
    Filters: [
      { Name: "metadata-options.http-tokens", Values: ["optional"] },
      { Name: "instance-state-name", Values: ["pending", "running", "stopping", "stopped"] },
    ],
  })) {
    for (const res of page.Reservations ?? []) {
      for (const i of res.Instances ?? []) {
        if (i.MetadataOptions?.HttpEndpoint === "disabled") continue; // IMDS switched off entirely
        found.push({
          id: i.InstanceId ?? "",
          name: i.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
          state: i.State?.Name ?? "",
          hop: i.MetadataOptions?.HttpPutResponseHopLimit ?? 1,
        });
      }
    }
  }

  const calls = found.length ? await imdsv1Calls(new CloudWatchClient({ region }), found.map((f) => f.id)) : new Map<string, number>();
  const rows: Row[] = found.map((f) => {
    const n = calls.get(f.id) ?? 0;
    const verdict = n > 0 ? "IMDSv1 IN USE: update software first" : f.state === "stopped" ? "no data (stopped): check before requiring" : "ready to require IMDSv2";
    return { Region: region, Instance: f.id, Name: f.name, State: f.state, HopLimit: f.hop, IMDSv1Calls: n, Verdict: verdict };
  });

  if (apply) {
    for (const r of rows.filter((x) => x.Verdict === "ready to require IMDSv2")) {
      try {
        await ec2.send(new ModifyInstanceMetadataOptionsCommand({ InstanceId: r.Instance, HttpTokens: "required", HttpEndpoint: "enabled" }));
        r.Verdict = "IMDSv2 now required";
      } catch (err) {
        r.Verdict = `failed: ${err instanceof Error ? err.name : String(err)}`;
      }
    }
    if (setDefault) {
      await ec2.send(new ModifyInstanceMetadataDefaultsCommand({ HttpTokens: "required", HttpPutResponseHopLimit: 2 }));
      console.log(`${region}: account default set to HttpTokens=required, hop limit 2 (new launches only)`);
    }
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of await listRegions()) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: skipped (${err instanceof Error ? err.name + ": " + err.message : String(err)})`);
    }
  }
  console.table(rows);
  const ready = rows.filter((r) => r.Verdict === "ready to require IMDSv2").length;
  console.log(`${rows.length} instances accept IMDSv1; ${rows.filter((r) => r.IMDSv1Calls > 0).length} made IMDSv1 calls in ${LOOKBACK_DAYS} days.`);
  if (!apply) console.log(`Report only: nothing changed. --apply would require IMDSv2 on ${ready} instance(s).`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

A stopped instance emits no metrics, so its IMDSv1 count is always zero. The script marks those instances instead of changing them, because the software on them hasn’t been observed.

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript

# Report only, every enabled Region
AWS_PROFILE=readonly npx tsx find-ec2-instances-without-imdsv2.ts

# Require IMDSv2 on instances with zero IMDSv1 calls, and make it the default for new launches
AWS_PROFILE=admin npx tsx find-ec2-instances-without-imdsv2.ts --regions=us-east-1 --apply --set-default

Sample output

Output

eu-west-1: account default HttpTokens=no-preference, enforced=no-preference
us-east-1: account default HttpTokens=no-preference, enforced=no-preference
┌─────────┬─────────────┬───────────────────────┬──────────────┬───────────┬──────────┬─────────────┬─────────────────────────────────────────────┐
│ (index) │ Region      │ Instance              │ Name         │ State     │ HopLimit │ IMDSv1Calls │ Verdict                                     │
├─────────┼─────────────┼───────────────────────┼──────────────┼───────────┼──────────┼─────────────┼─────────────────────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'i-0a1b2c3d4e5f60718' │ 'api-1'      │ 'running' │ 2        │ 0           │ 'ready to require IMDSv2'                   │
│ 1       │ 'us-east-1' │ 'i-0b2c3d4e5f6071829' │ 'legacy-etl' │ 'running' │ 1        │ 1824        │ 'IMDSv1 IN USE: update software first'      │
│ 2       │ 'us-east-1' │ 'i-0c3d4e5f607182930' │ 'jenkins'    │ 'stopped' │ 1        │ 0           │ 'no data (stopped): check before requiring' │
└─────────┴─────────────┴───────────────────────┴──────────────┴───────────┴──────────┴─────────────┴─────────────────────────────────────────────┘
3 instances accept IMDSv1; 1 made IMDSv1 calls in 14 days.
Report only: nothing changed. --apply would require IMDSv2 on 1 instance(s).

IDs are illustrative. legacy-etl is the one to investigate: something on it, often an old SDK or agent, still fetches credentials without a token. Getting every instance to IMDSv2 has a side benefit: Systems Manager’s Default Host Management Configuration only works on instances that require it, and the script to find EC2 instances not managed by Systems Manager shows which ones it would still miss.

What should you check before you require IMDSv2?

Requiring IMDSv2 takes effect immediately on a running instance, with no restart, and any IMDSv1 call after that fails with 401 Unauthorized. The EC2 User Guide’s recommended path to requiring IMDSv2 uses this order, and the script follows it:

  • Get MetadataNoToken to zero. Update the software making the calls. The minimum versions with IMDSv2 support include AWS CLI 1.16.289, the JavaScript SDK v2 2.722.0 and Boto3 1.12.6; every current SDK supports it. If your code is still on the JavaScript SDK v2, the guide to migrate a Node.js app from AWS SDK v2 to v3 is the longer-term fix.
  • Check the hop limit for containers. The token response has a hop limit of 1 by default, which can stop containers on the instance from reaching the IMDS. AWS’s own account-default example uses 2, and so does --set-default.
  • Watch MetadataNoTokenRejected afterwards. Once IMDSv1 is off, this metric counts rejected IMDSv1 calls, so a non-zero value points at software you missed.

How do you stop new instances launching with IMDSv1?

Account-level defaults are set per Region and apply at launch only; they don’t touch existing instances. A value set in the launch template or launch request still wins over the account default, and an AMI registered with ImdsSupport set to v2.0 (Amazon Linux 2023 is one) launches with IMDSv2 required unless something higher overrides it. Once every instance is clean, set HttpTokensEnforced to enabled with ModifyInstanceMetadataDefaults: launches that would allow IMDSv1 then fail. You can also add the ec2:MetadataHttpTokens condition key to an IAM policy or SCP so nobody can switch it back. Auto Scaling groups that still launch from a launch configuration carry their own metadata options, and launch configurations can’t be edited; the script to find Auto Scaling groups still using launch configurations drafts a launch template for each.

Troubleshooting

  • UnauthorizedOperation on ModifyInstanceMetadataOptions. The profile lacks the action, or an SCP with an ec2:Metadata* condition key blocks the values you sent. The error message names the denied action.
  • UnsupportedOperation when setting optional. IMDSv2 enforcement is enabled for the account in that Region, so IMDSv1 can’t be switched back on.
  • A Region is reported as skipped. The script prints the error and moves on. The usual cause is an SCP that denies API calls in that Region.
  • An app breaks after --apply. Set HttpTokens back to optional for that instance, then find the caller with MetadataNoTokenRejected.

For a wider EC2 inventory, the script to report EC2 instances by type, launch time and Region lists everything the IMDS check covers. For the same kind of hardening on serverless endpoints, the script to find public Lambda function URLs with AuthType NONE shows which functions anyone on the internet can call. And if instance credentials are stolen despite IMDSv2, GuardDuty reports them being used from outside AWS, so check GuardDuty is enabled in every AWS Region where your instances run.

Ask ChatWithCloud instead

You can also ask ChatWithCloud “Which EC2 instances in eu-west-1 have IMDSv2 set to optional?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, in the same way as the questions in the guide to analyze your AWS security posture with an AI CLI. It works in one profile and Region per session and runs generated code without a confirmation step, so ask for the report and connect ChatWithCloud to your AWS account with a read-only profile. The ChatWithCloud security model explains what stays on your machine.

Frequently asked questions

How do I check if an EC2 instance uses IMDSv2?

Look at MetadataOptions.HttpTokens in DescribeInstances. required means IMDSv2 only; optional means IMDSv1 still works. The CloudWatch metric MetadataNoToken tells you whether anything actually uses IMDSv1.

Does requiring IMDSv2 restart the instance?

No. You can change it on a running or stopped instance and it takes effect immediately, without a restart.

Does the account default change existing instances?

No. Account-level metadata defaults apply at launch only. Existing instances keep their settings until you modify them.

What hop limit should I use with Docker or EKS?

A hop limit of 1 can stop containers from reaching the IMDS. AMIs set to IMDSv2 by default launch with 2, which is the usual choice when containers on the instance need instance credentials.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud