Check AWS Backup Vaults for Vault Lock and Access Policies

A heavy round steel vault door standing open in a bright room

Photo by wutthichai charoenburi on Pexels

To check AWS Backup Vault Lock, call ListBackupVaults in each Region and read Locked, LockDate, MinRetentionDays and MaxRetentionDays. A locked vault with no LockDate is in governance mode; one with a LockDate is in compliance mode and becomes immutable once that date passes. Then read GetBackupVaultAccessPolicy for statements that deny deletes.

Backups are what you fall back on when an account is compromised, which is why attackers go after them first. A backup that an administrator can delete can also be deleted by anyone who steals that administrator’s credentials. AWS Backup Vault Lock and vault access policies are the two controls that stop that, and both are easy to leave half-configured.

This example is for engineers responsible for backup and recovery. The script reports the AWS Backup Vault Lock state of every standard and logically air-gapped vault in the Regions you choose, the days left before a compliance lock becomes permanent, and which delete actions each vault policy denies. It is report only on purpose: a compliance-mode lock can’t be undone after its grace time, so it’s not something to apply from a loop.

How does AWS Backup Vault Lock work?

A lock stops anyone, including the root user, from deleting a recovery point or changing its lifecycle before its retention period ends. The two modes differ in who can remove the lock itself. The details below come from the AWS Backup Vault Lock documentation.

Setting Governance mode Compliance mode
Who can remove the lock Users with sufficient IAM permissions, at any time Anyone allowed, during grace time only; after that no user and not AWS
LockDate Not set End of the grace time
Grace time None ChangeableForDays, 3 to 36,500 days
Can the vault be deleted? Yes, by users with permission Only when it holds no recovery points

Both modes accept optional retention limits. MinRetentionDays (at least 1) and MaxRetentionDays (up to 36,500) make new backup and copy jobs fail if their lifecycle falls outside the range. They don’t touch recovery points already in the vault, which keep their previous lifecycle.

Watch the grace time: AWS warns that recovery points with retention set to “Always” in a compliance-locked vault are kept forever once the grace time expires, and can’t be deleted. The script counts those recovery points in every locked vault so you can fix them while you still can.

A logically air-gapped vault comes with compliance-mode Vault Lock by design and can be shared with other accounts through AWS RAM for faster restores. The script lists both vault types.

What do vault access policies add?

Vault Lock protects recovery points, and a vault access policy controls who can call the AWS Backup APIs on a vault. The usual pattern is a Deny on backup:DeleteRecoveryPoint for every principal except a break-glass role. Two things to know: vault access policies don’t support wildcards in Action, so each action has to be listed, and they only cover AWS Backup APIs. EBS and RDS snapshots can also be reached through their own service APIs, which the policy doesn’t control.

The script checks five protective actions: DeleteRecoveryPoint, UpdateRecoveryPointLifecycle, DeleteBackupVault, PutBackupVaultAccessPolicy and DeleteBackupVaultAccessPolicy. It reports how many a Deny statement names. It doesn’t evaluate the conditions, so read the policy for any vault that scores low.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-backup. The guide to paginate any AWS API with SDK v3 paginators explains the paginate* helpers.
  • The Regions where you keep vaults, including copy destinations.
  • Know what’s being backed up first. A lock on an empty or unused vault protects nothing; run the AWS Backup coverage report example to see which resources reach a vault at all.

Which IAM permissions does it need?

backup-vault-lock-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListVaults",
      "Effect": "Allow",
      "Action": "backup:ListBackupVaults",
      "Resource": "*"
    },
    {
      "Sid": "ReadVaultPoliciesAndRecoveryPoints",
      "Effect": "Allow",
      "Action": ["backup:GetBackupVaultAccessPolicy", "backup:ListRecoveryPointsByBackupVault"],
      "Resource": "arn:aws:backup:*:111122223333:backup-vault:*"
    }
  ]
}

Everything here is read-only. The IAM policy generator for TypeScript code builds a starting policy from the script if you change it. For the other direction, look at who can call backup:DeleteBackupVaultLockConfiguration: that’s who can remove a governance lock. The example to find IAM policies that grant admin access is a quick way to see how many principals that is.

The script to check AWS Backup Vault Lock

check-aws-backup-vault-lock.ts

// check-aws-backup-vault-lock.ts
// For every AWS Backup vault (standard and logically air-gapped) in the Regions you pass, reports the
// Vault Lock mode (none, governance, compliance in grace time, compliance and immutable), the retention
// limits, the recovery point count, which delete actions the vault access policy denies, and, for locked
// vaults, recovery points with no retention period (kept forever). Report only: it never locks a vault.
// Usage: npx tsx check-aws-backup-vault-lock.ts [--regions us-east-1,eu-west-1]
import {
  BackupClient,
  GetBackupVaultAccessPolicyCommand,
  ResourceNotFoundException,
  paginateListBackupVaults,
  paginateListRecoveryPointsByBackupVault,
  type BackupVaultListMember,
  type VaultType,
} from "@aws-sdk/client-backup";

const args = process.argv.slice(2);
const i = args.indexOf("--regions");
const regions = (i >= 0 ? args[i + 1] : process.env.AWS_REGION ?? "us-east-1")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);

const DAY = 86_400_000;
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

// Actions a ransomware-minded vault policy usually denies to everyone except a break-glass role
const PROTECTIVE_ACTIONS = [
  "backup:DeleteRecoveryPoint",
  "backup:UpdateRecoveryPointLifecycle",
  "backup:DeleteBackupVault",
  "backup:PutBackupVaultAccessPolicy",
  "backup:DeleteBackupVaultAccessPolicy",
];

interface PolicyStatement {
  Effect?: string;
  Action?: string | string[];
}

async function deniedActions(backup: BackupClient, vault: string): Promise<string[] | "none"> {
  try {
    const out = await backup.send(new GetBackupVaultAccessPolicyCommand({ BackupVaultName: vault }));
    const doc = JSON.parse(out.Policy ?? "{}") as { Statement?: PolicyStatement | PolicyStatement[] };
    const statements = Array.isArray(doc.Statement) ? doc.Statement : doc.Statement ? [doc.Statement] : [];
    const denied = new Set<string>();
    for (const s of statements) {
      if (s.Effect !== "Deny") continue;
      for (const a of Array.isArray(s.Action) ? s.Action : s.Action ? [s.Action] : []) denied.add(a.toLowerCase());
    }
    return PROTECTIVE_ACTIONS.filter((a) => denied.has(a.toLowerCase()));
  } catch (err) {
    if (err instanceof ResourceNotFoundException) return "none"; // the vault has no access policy
    throw err;
  }
}

function lockMode(v: BackupVaultListMember): string {
  if (!v.Locked) return "none";
  if (!v.LockDate) return "governance";
  return v.LockDate.getTime() > Date.now() ? "compliance (grace time)" : "compliance (immutable)";
}

async function keptForever(backup: BackupClient, vault: string): Promise<number> {
  let count = 0;
  for await (const page of paginateListRecoveryPointsByBackupVault({ client: backup }, { BackupVaultName: vault })) {
    for (const rp of page.RecoveryPoints ?? []) {
      if (rp.Lifecycle?.DeleteAfterDays === undefined && rp.Lifecycle?.DeleteAfterEvent === undefined) count++;
    }
  }
  return count;
}

async function main(): Promise<void> {
  const rows: Record<string, string | number>[] = [];
  for (const region of regions) {
    const backup = new BackupClient({ region });
    const vaults = new Map<string, BackupVaultListMember>();
    const types: VaultType[] = ["BACKUP_VAULT", "LOGICALLY_AIR_GAPPED_BACKUP_VAULT"];
    for (const type of types) {
      for await (const page of paginateListBackupVaults({ client: backup }, { ByVaultType: type })) {
        for (const v of page.BackupVaultList ?? []) if (v.BackupVaultArn) vaults.set(v.BackupVaultArn, v);
      }
    }
    for (const v of vaults.values()) {
      const name = v.BackupVaultName ?? "?";
      const mode = lockMode(v);
      let policy = "?";
      try {
        const denied = await deniedActions(backup, name);
        policy = denied === "none" ? "no policy" : denied.length ? `denies ${denied.length}/${PROTECTIVE_ACTIONS.length}` : "no deny";
      } catch (err) {
        policy = `error: ${errText(err)}`;
      }
      const forever = v.Locked ? await keptForever(backup, name) : 0;
      let finding = "ok";
      if (mode === "none" && (v.NumberOfRecoveryPoints ?? 0) > 0) finding = "NO VAULT LOCK";
      else if (mode === "governance") finding = "governance: admins can remove the lock";
      else if (mode === "compliance (grace time)") {
        const days = Math.ceil(((v.LockDate?.getTime() ?? 0) - Date.now()) / DAY);
        finding = `REVIEW NOW: immutable in ${days} days`;
      }
      if (forever > 0) finding += `; ${forever} recovery points kept forever`;
      rows.push({
        Region: region,
        Vault: name,
        Type: v.VaultType === "LOGICALLY_AIR_GAPPED_BACKUP_VAULT" ? "air-gapped" : "standard",
        Lock: mode,
        LockDate: v.LockDate?.toISOString().slice(0, 10) ?? "-",
        MinDays: v.MinRetentionDays ?? "-",
        MaxDays: v.MaxRetentionDays ?? "-",
        Points: v.NumberOfRecoveryPoints ?? 0,
        Policy: policy,
        Finding: finding,
      });
    }
  }
  console.table(rows);
  const unlocked = rows.filter((r) => r.Finding === "NO VAULT LOCK").length;
  console.log(`${rows.length} vaults, ${unlocked} with recovery points and no Vault Lock. Report only: nothing was changed.`);
}

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

The script calls ListBackupVaults once per vault type and merges the results by ARN, so both standard and logically air-gapped vaults are covered.

How do you run it?

Terminal

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

AWS_PROFILE=readonly npx tsx check-aws-backup-vault-lock.ts --regions us-east-1,eu-west-1

Sample output

Output

┌─────────┬─────────────┬────────────────┬──────────────┬───────────────────────────┬──────────────┬─────────┬─────────┬────────┬──────────────┬───────────────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Vault          │ Type         │ Lock                      │ LockDate     │ MinDays │ MaxDays │ Points │ Policy       │ Finding                                                           │
├─────────┼─────────────┼────────────────┼──────────────┼───────────────────────────┼──────────────┼─────────┼─────────┼────────┼──────────────┼───────────────────────────────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'Default'      │ 'standard'   │ 'none'                    │ '-'          │ '-'     │ '-'     │ 212    │ 'no policy'  │ 'NO VAULT LOCK'                                                   │
│ 1       │ 'us-east-1' │ 'prod-daily'   │ 'standard'   │ 'governance'              │ '-'          │ 7       │ 35      │ 540    │ 'denies 2/5' │ 'governance: admins can remove the lock'                          │
│ 2       │ 'us-east-1' │ 'prod-monthly' │ 'standard'   │ 'compliance (grace time)' │ '2027-07-23' │ 365     │ 2557    │ 26     │ 'no policy'  │ 'REVIEW NOW: immutable in 3 days; 2 recovery points kept forever' │
│ 3       │ 'us-east-1' │ 'lag-copies'   │ 'air-gapped' │ 'compliance (immutable)'  │ '2027-01-10' │ 30      │ 400     │ 88     │ 'no policy'  │ 'ok'                                                              │
│ 4       │ 'eu-west-1' │ 'dr-copies'    │ 'standard'   │ 'compliance (immutable)'  │ '2026-11-02' │ 35      │ 400     │ 301    │ 'denies 5/5' │ 'ok'                                                              │
└─────────┴─────────────┴────────────────┴──────────────┴───────────────────────────┴──────────────┴─────────┴─────────┴────────┴──────────────┴───────────────────────────────────────────────────────────────────┘
5 vaults, 1 with recovery points and no Vault Lock. Report only: nothing was changed.

Vault names and counts are illustrative. Default holds 212 recovery points with no lock and no policy, so anyone with delete rights can remove them. prod-daily is in governance mode, which stops mistakes but not an attacker holding admin credentials. prod-monthly needs attention today: its compliance lock becomes permanent in 3 days and 2 recovery points in it have no retention, so they’d be kept (and billed) forever. dr-copies in eu-west-1 is the target state: compliance mode past its lock date, with a policy that denies all five actions.

What should you change after the report?

  1. Fix retention firstGive every recovery point in a vault that’s still in grace time a real retention period, or remove it, before the LockDate.
  2. Add a deny policyAttach a vault access policy that denies backup:DeleteRecoveryPoint to everyone except a break-glass role, using AWS’s example as the template.
  3. Try governance modeLock the vault in governance mode (no ChangeableForDays) and run a full backup cycle. New jobs that fall outside MinRetentionDays or MaxRetentionDays will fail, which is how you find plans with the wrong lifecycle.
  4. Move to compliance mode on purposeWhen retention is right, add ChangeableForDays and treat the grace time as a review window. Once it passes, the lock can’t be removed.
Governance-mode lock (no ChangeableForDays)

aws backup put-backup-vault-lock-configuration \
  --backup-vault-name prod-daily \
  --min-retention-days 7 \
  --max-retention-days 35

A lock only works if backups land in the vault, which depends on the source resources being set up right. The examples to find RDS instances without automated backups or encryption, enable DynamoDB point-in-time recovery on every table and find EBS volumes without a recent snapshot cover the most common gaps. Longer retention also means paying for more storage; the guide to S3 storage class cost for backups compares Glacier and Standard-IA for long-lived copies.

Immutability is one layer of a broader rule. The UK NCSC’s guidance on offline backups in an online world describes ransomware that encrypted connected backup storage along with the original data, and recommends keeping at least one backup offline. A compliance-locked vault, ideally in a separate account, is one way to apply that idea in AWS.

Troubleshooting

  • AccessDeniedException on some vaults. A vault access policy may deny GetBackupVaultAccessPolicy or ListRecoveryPointsByBackupVault to your role. The steps to troubleshoot IAM access denied errors apply to resource policies too.
  • Backup jobs fail after locking. The job’s lifecycle is shorter than MinRetentionDays or longer than MaxRetentionDays. Change the backup plan rule or target a different vault.
  • The run is slow. Counting recovery points with no retention pages through every recovery point in each locked vault. Large vaults take a while; the rest of the report is fast.
  • A vault shows “no policy” but you set one. Check the Region. Vault access policies are per vault, and copies in another Region need their own.

Ask ChatWithCloud instead

To check a single account quickly, ask ChatWithCloud “Which AWS Backup vaults in us-east-1 have no Vault Lock, and how many recovery points do they hold?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the result, in the same way as the questions in the guide to analyze AWS security posture with an AI CLI. Never ask it to lock a vault: it runs changes without a confirmation step, and a compliance lock is permanent. Use a read-only profile, as the ChatWithCloud security page explains.

Frequently asked questions

What is the difference between governance and compliance mode in AWS Backup Vault Lock?

Users with the right IAM permissions can remove a governance-mode lock at any time. A compliance-mode lock can only be removed during its grace time; after the LockDate, no user and not AWS can change or delete it.

How long is the Vault Lock grace time?

You choose it with ChangeableForDays. It must be at least 3 days (72 hours) and at most 36,500 days.

Does AWS Backup Vault Lock cost extra?

No. Vault Lock is available at no additional charge, but normal AWS Backup storage charges apply to everything the lock keeps.

What happens to a locked vault if I close the AWS account?

AWS suspends the account for 90 days with backups intact. If you don’t reopen it in that time, AWS deletes the vault contents even with Vault Lock in place.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud