Check if an S3 Object Exists in TypeScript (SDK v3)

Rows of metal filing cabinet drawers in an archive room

Photo by Maksym Kaharlytskyi on Unsplash

To check if an S3 object exists in TypeScript, send HeadObjectCommand from @aws-sdk/client-s3. A successful response means the object exists and returns its size and metadata without downloading it. A 404 (NotFound) means it doesn’t exist. A 403 is ambiguous: without s3:ListBucket, S3 returns 403 instead of 404 for missing keys.

This example is for TypeScript developers who need to check if an S3 object exists before they read it, overwrite it or hand a link to a user. You’ll get a small, read-only script built on the AWS SDK for JavaScript v3, an IAM policy that makes S3 report missing keys honestly, and a clear rule for handling the 403 case that trips up most first attempts.

It belongs to our collection of runnable AWS practical examples, next to the S3 scripts for uploads, presigned URLs and bucket audits.

What does the script do?

  1. Sends one HEAD requestHeadObjectCommand asks S3 for the object’s metadata only. No body is transferred, so it’s cheap and fast even for multi-gigabyte objects.
  2. Reads the HTTP status, not the messageHEAD responses have no body, so there is no XML error code to parse. The SDK names a 404 NotFound; for anything else the script reads $metadata.httpStatusCode.
  3. Returns three outcomesExists (with size, LastModified, ETag, content type and storage class), missing (exit code 1) or an error it refuses to guess about, such as a 403 or a wrong region (exit code 2).

The script never writes, copies or deletes anything. You can drop the objectExists() function into your own code and keep the same three-way result.

Why does HeadObject return 403 instead of 404?

This is the part most snippets get wrong. When a key doesn’t exist, S3 decides what to tell you based on whether you’re allowed to list the bucket. If your identity has s3:ListBucket on the bucket, a missing key returns 404 Not Found. If it doesn’t, S3 returns 403 Forbidden, so that callers without list rights can’t probe which keys exist.

That means code like catch { return false; } silently turns “you have no permission” into “the object is missing”. A job that uploads a file only if it’s missing would then overwrite existing objects, or a health check would report data loss that isn’t real. The script below treats 404 as “missing” and throws on 403 with a message that names both possibilities.

Watch for delete markers: in a versioned bucket, a deleted object still has older versions, but HeadObject without a VersionId returns 404 because the current version is a delete marker. Pass a version ID to check a specific version. The script to find S3 buckets without versioning shows which buckets this applies to.

Prerequisites

  • Node.js 18 or later, npm and tsx (or compile with tsc).
  • The @aws-sdk/client-s3 package.
  • An AWS profile with credentials, and AWS_REGION set to the bucket’s region.

Which IAM permissions does it need?

HeadObject is authorized by s3:GetObject on the object ARN. Add s3:ListBucket on the bucket ARN so a missing key comes back as 404 rather than 403. If you pass a version ID, you also need s3:GetObjectVersion. Replace my-bucket with your bucket name.

s3-object-exists-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "HeadObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::my-bucket/*"
    },
    {
      "Sid": "ReportMissingKeysAs404",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-bucket"
    }
  ]
}

If the objects are encrypted with a customer managed KMS key, HeadObject also needs kms:Decrypt on that key. Before you widen anything, run the policy past the checklist to review a generated IAM policy for least privilege. If you extend the script, the free IAM policy generator for TypeScript code drafts the extra actions for you.

The full script to check if an S3 object exists in TypeScript

s3-object-exists.ts

// s3-object-exists.ts
// Checks whether an S3 object exists with HeadObject (no body is downloaded).
// Exits 0 if the object exists, 1 if it doesn't, 2 on any other error.
// Read-only. Usage: npx tsx s3-object-exists.ts <bucket> <key> [versionId]
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";

type ObjectInfo = {
  exists: true;
  size: number;
  lastModified?: Date;
  etag?: string;
  contentType?: string;
  storageClass: string;
};

// Returns object metadata, or { exists: false } for a genuine 404.
// Throws on 403 and every other error, so "no access" is never mistaken for "missing".
async function objectExists(
  s3: S3Client,
  bucket: string,
  key: string,
  versionId?: string,
): Promise<ObjectInfo | { exists: false }> {
  try {
    const head = await s3.send(
      new HeadObjectCommand({ Bucket: bucket, Key: key, VersionId: versionId }),
    );
    return {
      exists: true,
      size: head.ContentLength ?? 0,
      lastModified: head.LastModified,
      etag: head.ETag,
      contentType: head.ContentType,
      storageClass: head.StorageClass ?? "STANDARD", // S3 omits the header for STANDARD
    };
  } catch (err) {
    // HEAD responses have no body, so branch on the HTTP status, not the error message.
    const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
    if (status === 404 || (err instanceof Error && err.name === "NotFound")) {
      return { exists: false };
    }
    if (status === 403) {
      throw new Error(
        `403 Forbidden for s3://${bucket}/${key}. Without s3:ListBucket on the bucket, ` +
          "S3 returns 403 instead of 404 for missing keys, so this may mean either " +
          "'no permission' or 'does not exist'.",
      );
    }
    if (status === 301) {
      throw new Error(`Bucket ${bucket} is in another region. Set AWS_REGION to the bucket's region.`);
    }
    throw err;
  }
}

async function main(): Promise<void> {
  const [bucket, key, versionId] = process.argv.slice(2);
  if (!bucket || !key) {
    console.error("Usage: npx tsx s3-object-exists.ts <bucket> <key> [versionId]");
    process.exit(2);
  }
  const s3 = new S3Client({}); // region and credentials from AWS_REGION / AWS_PROFILE
  const result = await objectExists(s3, bucket, key, versionId);

  if (!result.exists) {
    console.log(`MISSING  s3://${bucket}/${key}`);
    process.exit(1);
  }
  console.log(`EXISTS   s3://${bucket}/${key}`);
  console.log(`  size          ${result.size} bytes`);
  console.log(`  last modified ${result.lastModified?.toISOString() ?? "unknown"}`);
  console.log(`  etag          ${result.etag ?? "unknown"}`);
  console.log(`  content type  ${result.contentType ?? "unknown"}`);
  console.log(`  storage class ${result.storageClass}`);
}

main().catch((err: unknown) => {
  console.error(err instanceof Error ? err.message : err);
  process.exit(2);
});

The SDK exports typed error classes such as NotFound and NoSuchKey; you can browse them in the client-s3 package in the aws-sdk-js-v3 repository. NoSuchKey is what GetObjectCommand throws, because a GET response carries an XML error body. HeadObjectCommand never throws NoSuchKey, which is why checking err.name === "NoSuchKey" after a HEAD request never matches. When you need the contents rather than a yes or no, the guide to read an S3 object with AWS SDK v3 GetObject shows the NoSuchKey check in context.

How do you run it?

Terminal

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

AWS_PROFILE=readonly AWS_REGION=eu-west-1 \
  npx tsx s3-object-exists.ts app-uploads-prod invoices/2026/09/inv-10423.pdf
echo "exit code: $?"

# Check a specific version in a versioned bucket
AWS_PROFILE=readonly AWS_REGION=eu-west-1 \
  npx tsx s3-object-exists.ts app-uploads-prod reports/q3.csv 3HL4kqtJlcpXroDTDmjVBH40Nrjfkd

The exit codes make the script usable in shell pipelines and CI: 0 exists, 1 missing, 2 anything that needs a human. Only exit code 1 should ever lead to “create the object”.

Sample output

Output (illustrative)

$ npx tsx s3-object-exists.ts app-uploads-prod invoices/2026/09/inv-10423.pdf
EXISTS   s3://app-uploads-prod/invoices/2026/09/inv-10423.pdf
  size          48213 bytes
  last modified 2026-09-14T08:02:51.000Z
  etag          "5d41402abc4b2a76b9719d911017c592"
  content type  application/pdf
  storage class STANDARD

$ npx tsx s3-object-exists.ts app-uploads-prod invoices/2026/09/inv-99999.pdf
MISSING  s3://app-uploads-prod/invoices/2026/09/inv-99999.pdf

$ AWS_PROFILE=no-list npx tsx s3-object-exists.ts app-uploads-prod invoices/2026/09/inv-99999.pdf
403 Forbidden for s3://app-uploads-prod/invoices/2026/09/inv-99999.pdf. Without s3:ListBucket on the bucket, S3 returns 403 instead of 404 for missing keys, so this may mean either 'no permission' or 'does not exist'.

Bucket names, keys and values above are illustrative. STANDARD is shown when S3 omits the storage class header, which it does for Standard objects.

HeadObject vs GetObject vs ListObjectsV2: which should you use?

Approach Permission Missing key returns Use it when
HeadObjectCommand s3:GetObject (+ s3:ListBucket for 404) 404 NotFound You need a yes/no answer plus size or ETag. The default choice.
GetObjectCommand s3:GetObject (+ s3:ListBucket for 404) 404 NoSuchKey You’re going to read the body anyway, so a separate HEAD would be an extra request.
ListObjectsV2Command with Prefix s3:ListBucket only Empty Contents The caller may list but not read, or you want to check many keys under one prefix at once. To page past 1,000 keys, list all objects in an S3 bucket with AWS SDK v3.

S3 has strong read-after-write consistency, so a HEAD request right after a successful PutObject sees the new object. If you’re checking before an upload, the guide to upload a file to S3 with S3Client in TypeScript shows the write side. For a race-free “create only if missing”, send PutObjectCommand with IfNoneMatch: "*" instead of HEAD-then-PUT.

Troubleshooting

  • 403 for a key you know is missing. The identity lacks s3:ListBucket on the bucket ARN, the most common cause. Other causes are a bucket policy or SCP deny, or a KMS key policy. The step-by-step guide to troubleshoot AWS IAM access denied errors walks through each layer.
  • 404 but the object is in the console. Check the exact key: S3 keys are case-sensitive, have no leading slash and may contain URL-encoded characters. A 404 also comes back when the bucket itself doesn’t exist, because HEAD has no body to carry NoSuchBucket.
  • 301 or a region error. The bucket is in a different region from your client. Set AWS_REGION, or create the client with followRegionRedirects: true.
  • The check passes but a presigned link fails. The link was signed by a different identity. See creating a presigned S3 download URL with SDK v3 for how the signer’s permissions apply.

Ask ChatWithCloud instead

For a one-off check you can ask ChatWithCloud “Does s3://app-uploads-prod/invoices/2026/09/inv-10423.pdf exist, and how big is it?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and answers in plain English, following the loop in how ChatWithCloud runs AWS SDK code locally. Because it runs changes without a confirmation step, connect ChatWithCloud with a read-only AWS profile for questions like this. Keep the script above for application code, where you want a deterministic result.

Frequently asked questions

How do I check if an S3 key exists without downloading it?

Use HeadObjectCommand. It returns metadata such as ContentLength and ETag without the body, so the cost is one request regardless of object size.

Why does HeadObject throw an error with no message?

HEAD responses have no body, so S3 can’t send an XML error. The SDK names a 404 NotFound; for other statuses read err.$metadata.httpStatusCode.

Does waitUntilObjectExists replace this check?

waitUntilObjectExists from @aws-sdk/client-s3 polls HeadObject until the object appears or a timeout passes. Use it when you expect an object to arrive soon; use a single HEAD for a yes/no answer now.

Can I check whether many objects exist at once?

There is no batch HEAD. For keys under one prefix, a single ListObjectsV2Command call returns up to 1,000 keys per page, which is cheaper than one HEAD per key.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud