Find IAM SSH Public Keys and Service-Specific Credentials

Small metal padlock resting on a dark laptop keyboard

Photo by Sasun Bughdaryan on Unsplash

IAM SSH public keys and service-specific credentials are long-lived credentials attached to IAM users that access key audits don’t cover. List them per user with ListSSHPublicKeys, ListServiceSpecificCredentials and ListSigningCertificates. Each returns a status and a creation or upload date, so you can find old, active ones and set them to Inactive before deleting them.

This example is for engineers who already rotate IAM access keys and want the rest of the long-lived credentials on the same list. IAM users can also hold SSH public keys for CodeCommit, service-specific credentials such as Git credentials, Amazon Keyspaces passwords and long-term API keys, and X.509 signing certificates. None of them appear in an access key report.

The TypeScript script for the AWS SDK for JavaScript v3 lists all IAM SSH public keys, service-specific credentials and signing certificates for every IAM user, with age, status and expiry. It’s report only unless you pass --user and --apply: then it sets that one user’s active credentials of these types to Inactive, and with --delete-inactive it deletes the ones that were already inactive. Access keys are covered by the script to find old and unused IAM access keys.

Which IAM SSH public keys and service-specific credentials can a user have?

Credential Used for Listed by Limit or expiry
SSH public key Authenticating to CodeCommit repositories over SSH, and nothing else ListSSHPublicKeys No expiry; ssh-rsa or PEM format, 2,048 to 16,384 bits
Service-specific credential One service only: CodeCommit Git credentials (codecommit.amazonaws.com), Amazon Keyspaces (cassandra.amazonaws.com), or long-term API keys for Amazon Bedrock, CloudWatch, CloudWatch Logs and Claude Platform on AWS ListServiceSpecificCredentials 2 per service per user; API keys can have an expiry date
Signing certificate X.509 certificate that some AWS services use to validate requests signed with the matching private key ListSigningCertificates No expiry field in the API

Long-term API keys are the newest member of the group. The IAM guide to API keys for AWS services says they carry a higher security risk than short-term API keys and recommends short-term keys or temporary credentials where possible. The Bedrock service name is bedrock.amazonaws.com, and keys created without --credential-age-days never expire.

What none of these list APIs return is a last-used date. IAM’s docs point to CloudTrail events from the service where the credential is used. Age and status are the signals the script can give you; confirm use in CloudTrail before you delete anything that’s still active.

Why do old credentials of these types matter?

They’re static secrets tied to a person or a bot account, often created once for a migration or a CI job and forgotten. The OWASP Secrets Management Cheat Sheet treats rotation, revocation and expiration as normal stages of a secret’s life; a Git credential created years ago for a build server that no longer exists has skipped all three.

They also outlive the controls you added later. An IAM user without MFA can still push to CodeCommit with an SSH key, and the script to find IAM users without MFA won’t tell you that. Users who hold these credentials often have directly attached policies too; the check to find IAM users with directly attached policies is a good companion.

What does the script do?

  1. Lists IAM userspaginateListUsers across the whole account. IAM is global, so there’s no Region loop.
  2. Lists SSH public keyspaginateListSSHPublicKeys per user: SSHPublicKeyId, Status, UploadDate.
  3. Lists service-specific credentialsListServiceSpecificCredentials per user, following Marker by hand because the SDK has no paginator for it. Rows show ServiceName, CreateDate and ExpirationDate when set.
  4. Lists signing certificatespaginateListSigningCertificates per user.
  5. Flags and actsActive credentials older than --max-age-days (default 90) are marked “rotate or remove”, expired API keys “delete”, and inactive ones “delete when confirmed”. With --apply --user, UpdateSSHPublicKey, UpdateServiceSpecificCredential and UpdateSigningCertificate set active ones to Inactive; --delete-inactive calls the matching Delete* operation for inactive ones.

Prerequisites

  • Node.js 18 or later, npm, tsx and @aws-sdk/client-iam.
  • A read-only profile for the report and an IAM admin profile for --apply.
  • The owners of the bot users. Deactivating a Git credential breaks whatever pipeline still uses it, which is the point, but tell the team first.

Which IAM permissions does it need?

iam-user-credentials-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListUsers",
      "Effect": "Allow",
      "Action": "iam:ListUsers",
      "Resource": "*"
    },
    {
      "Sid": "ReadUserCredentials",
      "Effect": "Allow",
      "Action": [
        "iam:ListSSHPublicKeys",
        "iam:ListServiceSpecificCredentials",
        "iam:ListSigningCertificates"
      ],
      "Resource": "arn:aws:iam::111122223333:user/*"
    },
    {
      "Sid": "DeactivateOrDeleteWithApply",
      "Effect": "Allow",
      "Action": [
        "iam:UpdateSSHPublicKey",
        "iam:DeleteSSHPublicKey",
        "iam:UpdateServiceSpecificCredential",
        "iam:DeleteServiceSpecificCredential",
        "iam:UpdateSigningCertificate",
        "iam:DeleteSigningCertificate"
      ],
      "Resource": "arn:aws:iam::111122223333:user/*"
    }
  ]
}

Remove the last statement for an audit-only role. If a call is denied, the guide to troubleshoot AWS IAM access denied errors step by step helps find the policy responsible.

The script to list IAM SSH public keys and service-specific credentials

find-iam-users-with-ssh-keys-and-service-credentials.ts

// find-iam-users-with-ssh-keys-and-service-credentials.ts
// Lists the long-lived IAM user credentials that access key audits miss: SSH public keys (CodeCommit),
// service-specific credentials (Git credentials, Amazon Keyspaces passwords, API keys) and X.509 signing
// certificates, with age and status. Report only unless you pass --apply with --user: then it sets
// that user's active credentials of these types to Inactive, and with --delete-inactive it deletes the
// ones that are already Inactive.
// Usage:
//   npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts [--max-age-days 90]
//   npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts --user build-bot --apply
//   npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts --user build-bot --apply --delete-inactive
import {
  DeleteSSHPublicKeyCommand,
  DeleteServiceSpecificCredentialCommand,
  DeleteSigningCertificateCommand,
  IAMClient,
  ListServiceSpecificCredentialsCommand,
  UpdateSSHPublicKeyCommand,
  UpdateServiceSpecificCredentialCommand,
  UpdateSigningCertificateCommand,
  paginateListSSHPublicKeys,
  paginateListSigningCertificates,
  paginateListUsers,
  type ServiceSpecificCredentialMetadata,
} from "@aws-sdk/client-iam";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const maxAgeDays = Number(flag("--max-age-days") ?? "90");
const targetUser = flag("--user");
const apply = args.includes("--apply");
const deleteInactive = args.includes("--delete-inactive");
const iam = new IAMClient({ region: process.env.AWS_REGION ?? "us-east-1" });

type Kind = "ssh-key" | "service-credential" | "signing-cert";
interface Cred {
  User: string;
  Kind: Kind;
  Service: string;
  Id: string;
  Status: string;
  Created: string;
  AgeDays: number;
  Expires: string;
  Action: string;
}

const day = (d: Date | undefined) => (d ? d.toISOString().slice(0, 10) : "-");
const ageOf = (d: Date | undefined) => (d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : 0);

// ListServiceSpecificCredentials has no paginator in the SDK, so follow Marker by hand.
async function serviceCreds(userName: string): Promise<ServiceSpecificCredentialMetadata[]> {
  const out: ServiceSpecificCredentialMetadata[] = [];
  let marker: string | undefined;
  do {
    const res = await iam.send(new ListServiceSpecificCredentialsCommand({ UserName: userName, Marker: marker }));
    out.push(...(res.ServiceSpecificCredentials ?? []));
    marker = res.IsTruncated ? res.Marker : undefined;
  } while (marker);
  return out;
}

function action(status: string, created: Date | undefined, expires?: Date): string {
  if (status !== "Active") return "inactive: delete when confirmed";
  if (expires && expires.getTime() < Date.now()) return "expired: delete";
  return ageOf(created) > maxAgeDays ? `older than ${maxAgeDays} days: rotate or remove` : "recent";
}

async function credsFor(userName: string): Promise<Cred[]> {
  const out: Cred[] = [];
  for await (const page of paginateListSSHPublicKeys({ client: iam }, { UserName: userName })) {
    for (const k of page.SSHPublicKeys ?? []) {
      out.push({ User: userName, Kind: "ssh-key", Service: "CodeCommit", Id: k.SSHPublicKeyId ?? "", Status: k.Status ?? "", Created: day(k.UploadDate), AgeDays: ageOf(k.UploadDate), Expires: "-", Action: action(k.Status ?? "", k.UploadDate) });
    }
  }
  for (const c of await serviceCreds(userName)) {
    out.push({ User: userName, Kind: "service-credential", Service: c.ServiceName ?? "", Id: c.ServiceSpecificCredentialId ?? "", Status: c.Status ?? "", Created: day(c.CreateDate), AgeDays: ageOf(c.CreateDate), Expires: day(c.ExpirationDate), Action: action(c.Status ?? "", c.CreateDate, c.ExpirationDate) });
  }
  for await (const page of paginateListSigningCertificates({ client: iam }, { UserName: userName })) {
    for (const c of page.Certificates ?? []) {
      out.push({ User: userName, Kind: "signing-cert", Service: "X.509", Id: c.CertificateId ?? "", Status: c.Status ?? "", Created: day(c.UploadDate), AgeDays: ageOf(c.UploadDate), Expires: "-", Action: action(c.Status ?? "", c.UploadDate) });
    }
  }
  return out;
}

async function deactivateOrDelete(c: Cred): Promise<string> {
  const UserName = c.User;
  if (c.Status === "Active") {
    if (c.Kind === "ssh-key") await iam.send(new UpdateSSHPublicKeyCommand({ UserName, SSHPublicKeyId: c.Id, Status: "Inactive" }));
    if (c.Kind === "service-credential") await iam.send(new UpdateServiceSpecificCredentialCommand({ UserName, ServiceSpecificCredentialId: c.Id, Status: "Inactive" }));
    if (c.Kind === "signing-cert") await iam.send(new UpdateSigningCertificateCommand({ UserName, CertificateId: c.Id, Status: "Inactive" }));
    return "set to Inactive";
  }
  if (!deleteInactive) return c.Action;
  if (c.Kind === "ssh-key") await iam.send(new DeleteSSHPublicKeyCommand({ UserName, SSHPublicKeyId: c.Id }));
  if (c.Kind === "service-credential") await iam.send(new DeleteServiceSpecificCredentialCommand({ UserName, ServiceSpecificCredentialId: c.Id }));
  if (c.Kind === "signing-cert") await iam.send(new DeleteSigningCertificateCommand({ UserName, CertificateId: c.Id }));
  return "deleted";
}

async function main(): Promise<void> {
  if (apply && !targetUser) {
    console.error("--apply needs --user with the IAM user name to change.");
    process.exit(1);
  }
  const creds: Cred[] = [];
  let users = 0;
  for await (const page of paginateListUsers({ client: iam }, {})) {
    for (const u of page.Users ?? []) {
      if (!u.UserName) continue;
      users++;
      creds.push(...(await credsFor(u.UserName)));
    }
  }

  if (apply && targetUser) {
    for (const c of creds.filter((x) => x.User === targetUser)) {
      try {
        c.Action = await deactivateOrDelete(c);
      } catch (err) {
        c.Action = `error: ${err instanceof Error ? err.name : String(err)}`;
      }
    }
  }

  const active = creds.filter((c) => c.Status === "Active").length;
  console.log(`IAM users: ${users}; SSH keys, service-specific credentials and signing certificates: ${creds.length} (${active} active)`);
  creds.sort((a, b) => b.AgeDays - a.AgeDays);
  if (creds.length) console.table(creds);
  if (!apply) console.log("Report only. Pass --user and --apply to deactivate one user's credentials.");
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-iam
npm install --save-dev tsx typescript @types/node

# Report for the whole account
AWS_PROFILE=readonly npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts

# Deactivate one user's active SSH keys, service-specific credentials and signing certificates
AWS_PROFILE=iam-admin npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts --user build-bot --apply

# A week later, delete the ones that are now inactive
AWS_PROFILE=iam-admin npx tsx find-iam-users-with-ssh-keys-and-service-credentials.ts --user build-bot --apply --delete-inactive

Sample output

Output

IAM users: 2; SSH keys, service-specific credentials and signing certificates: 5 (4 active)
┌─────────┬─────────────┬──────────────────────┬────────────────────────────┬────────────────┬────────────┬──────────────┬─────────┬──────────────┬────────────────────────────────────────┐
│ (index) │ User        │ Kind                 │ Service                    │ Id             │ Status     │ Created      │ AgeDays │ Expires      │ Action                                 │
├─────────┼─────────────┼──────────────────────┼────────────────────────────┼────────────────┼────────────┼──────────────┼─────────┼──────────────┼────────────────────────────────────────┤
│ 0       │ 'build-bot' │ 'signing-cert'       │ 'X.509'                    │ 'CERTEXAMPLE'  │ 'Active'   │ '2021-04-08' │ 2000    │ '-'          │ 'older than 90 days: rotate or remove' │
│ 1       │ 'build-bot' │ 'ssh-key'            │ 'CodeCommit'               │ 'APKAEXAMPLE2' │ 'Inactive' │ '2024-04-12' │ 900     │ '-'          │ 'inactive: delete when confirmed'      │
│ 2       │ 'build-bot' │ 'ssh-key'            │ 'CodeCommit'               │ 'APKAEXAMPLE1' │ 'Active'   │ '2024-07-09' │ 812     │ '-'          │ 'older than 90 days: rotate or remove' │
│ 3       │ 'build-bot' │ 'service-credential' │ 'codecommit.amazonaws.com' │ 'ACCAEXAMPLE1' │ 'Active'   │ '2024-12-28' │ 640     │ '-'          │ 'older than 90 days: rotate or remove' │
│ 4       │ 'alice'     │ 'service-credential' │ 'bedrock.amazonaws.com'    │ 'ACCAEXAMPLE2' │ 'Active'   │ '2026-09-17' │ 12      │ '2026-10-17' │ 'recent'                               │
└─────────┴─────────────┴──────────────────────┴────────────────────────────┴────────────────┴────────────┴──────────────┴─────────┴──────────────┴────────────────────────────────────────┘
Report only. Pass --user and --apply to deactivate one user's credentials.

Users and IDs are illustrative. build-bot holds an active signing certificate that’s over five years old, two SSH keys (one already inactive) and a CodeCommit Git credential. alice has a Bedrock API key created 12 days ago that expires in 30 days, which is how long-term API keys should look.

How do you remove these credentials without breaking a pipeline?

IAM’s own rotation procedure for service-specific credentials works for all three types:

  1. Create the replacementIf the credential is still needed, create a second one for the same service and user (the limit of two per service exists for this), or move the workload to temporary credentials from a role.
  2. Switch the clientsUpdate every application to the new credential and confirm it works.
  3. Deactivate the old oneRun the script with --user and --apply. Inactive credentials can be reactivated if something you missed breaks.
  4. Delete after a waiting periodWhen nothing has complained, run it again with --delete-inactive.

For Keyspaces, AWS also supports access keys with the SigV4 plugin for Cassandra drivers, which avoids a separate password. For Bedrock, prefer short-term API keys or SDK calls with role credentials; the guide to invoke a Bedrock model with AWS SDK v3 uses the normal credential chain. To see which bot users could be replaced by roles altogether, the script to find unused IAM roles and the IAM identity provider audit show what federation is already set up.

Troubleshooting

  • NoSuchEntityException. The SDK describes it as a request that referenced a resource entity that does not exist. The user or credential was deleted after the scan; run the report again.
  • A credential you know about doesn’t appear. The script lists IAM users only. SSH key pairs for EC2 instances are a different resource; the example to find unused EC2 key pairs covers those.
  • The run is slow in a large account. It makes three calls per user. If you see throttling errors, the guide to configure retries and timeouts in AWS SDK v3 shows how to raise the retry count.
  • You need proof of use. Search CloudTrail for events from CodeCommit, Keyspaces or Bedrock with the user name. The check to confirm CloudTrail is enabled in all Regions makes sure those events exist.

Ask ChatWithCloud instead

Ask ChatWithCloud “Which IAM users have active SSH keys or Git credentials?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the list. It can be wrong, and because it uses SDK v2, API features added after v2’s end of support on 8 September 2025 may be missing. It also runs generated code without a confirmation step, so use a read-only profile. The ChatWithCloud security and data handling page explains what’s sent for processing: the JSON these calls return, which is metadata and public certificates, not passwords, API key values or private keys.

Frequently asked questions

How do I list SSH public keys for an IAM user?

Run aws iam list-ssh-public-keys --user-name <user>. It returns each key’s ID, status and upload date. IAM SSH keys are used only for CodeCommit.

What are IAM service-specific credentials?

User names and passwords or API keys that work with one AWS service only, such as CodeCommit Git credentials, Amazon Keyspaces passwords or Bedrock API keys. Each IAM user can have up to two per service.

How do I find all Bedrock API keys in an account?

Run aws iam list-service-specific-credentials --service-name bedrock.amazonaws.com --all-users.

Does deactivating a credential delete it?

No. An inactive credential stops working but can be reactivated. Delete it once you’re sure nothing needs it.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud