S3 Presigned URL Workflows for Secure Direct-to-Cloud Uploads

A presigned URL lets a browser write an object into your bucket without ever holding an AWS credential, which removes your API server from the byte path entirely. The cost of that is a signing endpoint that must decide, before it signs anything, exactly which key, which content type and which size window it is willing to authorise — because once the URL is out, S3 will honour it without asking you again.

This topic sits inside backend validation and cloud storage architecture and is the credential-issuing half of direct-to-cloud upload patterns. The signature itself is cheap — an HMAC chain over a canonicalised string, computed locally with no network call to AWS. Everything that makes this hard in production is around the edges: clock drift between your signer and S3, credentials that expire before the URL does, CORS preflights that never reach the signed request, and a client that retries a 403 forever because nobody distinguished “expired” from “forbidden”.

Presigned URL request and upload sequence The browser asks the backend for a signed URL, the backend validates and signs a scoped PutObject command, the browser uploads bytes straight to S3, reads the ETag, and reports the committed key back to the backend. Browser Signing API S3 1. intent: filename, type, byte count 2. validate, pick the key, sign 3. url + key + expiresAt + required headers 4. PUT bytes, Content-Type must match the signature 5. 200 OK, ETag, x-amz-request-id 6. commit: key + ETag, then scan and index
Six hops, and your servers only carry two of them. Step 6 is the one teams forget — without a commit callback you have objects in a bucket that no database row knows about.

Prerequisites

  • [ ] Node 20+ (for node:crypto, crypto.randomUUID and native fetch in the verification scripts).
  • [ ] @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner at v3.729 or later — earlier versions predate the checksum default discussed under Edge cases.
  • [ ] An S3 bucket with Block Public Access fully on; presigned URLs work fine against a private bucket and should never be paired with a public one.
  • [ ] A signing identity — an IAM role for a Lambda or container, not a long-lived user key — with s3:PutObject on one prefix only.
  • [ ] A CORS configuration on the bucket that exposes ETag, as covered in CORS configuration for uploads.
  • [ ] The AWS CLI v2 available locally for the verification section.

How it works

Nothing about a presigned URL is stored on the AWS side. Your signer takes the request it would have made — method, path, query, host, the headers it wants to bind — flattens it into a canonical string, and computes an HMAC-SHA256 over it using a key derived from your secret access key. The result goes into the query string as X-Amz-Signature. When the request eventually arrives, S3 rebuilds that same canonical string from what it actually received, recomputes the HMAC with its copy of your secret, and compares. Equal means authorised; different by one byte means SignatureDoesNotMatch.

That has a consequence worth internalising: there is no revocation list. A signed URL is valid until its own clock runs out or the credential behind it stops working. You cannot un-issue one. That is why the useful controls are all upstream — short expiry windows, a key you chose rather than one the client supplied, and rate limiting on presigned URL issuance so an attacker cannot mint ten thousand of them.

What actually travels in the URL

Six query parameters carry the whole authorisation. Everything except the signature is plaintext and inspectable, which is the point: S3 needs the same inputs you had in order to recompute the HMAC.

Anatomy of a presigned PUT URL The endpoint and key form the request line, followed by six X-Amz query parameters: algorithm, credential scope, date, expiry seconds, signed header list, and the HMAC signature itself. Six fields S3 reads back out of the query string https://media-uploads.s3.eu-west-1.amazonaws.com/user-uploads/2026/07/a1b2c3d4.png? parameter value what it controls X-Amz-Algorithm AWS4-HMAC-SHA256 which signing scheme S3 should use X-Amz-Credential AKIA…/20260726/eu-west-1/s3/… key id plus date, region, service scope X-Amz-Date 20260726T101500Z start of the validity window X-Amz-Expires 900 window width in seconds, max 604800 X-Amz-SignedHeaders content-type;host headers the client must resend exactly X-Amz-Signature f66d4daf…ed3fbed9 HMAC over the canonical request Temporary credentials add a seventh: X-Amz-Security-Token, the STS token, sorted before X-Amz-SignedHeaders. Change any byte of the path, query or signed headers and the recomputed HMAC no longer matches.
Only the last field is a secret-derived value — and even it reveals nothing, because the HMAC key is never transmitted.

Deriving the signature by hand

The SDK is convenient but opaque, and when a signature mismatch lands in production the fastest way to find the offending byte is to build the canonical request yourself and diff it against the one S3 echoes back. This is the whole algorithm, with no AWS dependency:

import { createHash, createHmac } from "node:crypto";

// AWS uses the RFC 3986 unreserved set; encodeURIComponent leaves !'()* alone.
const enc = (s: string): string =>
  encodeURIComponent(s).replace(
    /[!'()*]/g,
    (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
  );

const hmac = (key: Buffer | string, data: string): Buffer =>
  createHmac("sha256", key).update(data, "utf8").digest();

const sha256Hex = (data: string): string =>
  createHash("sha256").update(data, "utf8").digest("hex");

export interface PresignInput {
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken?: string;
  region: string;
  bucket: string;
  key: string;
  expiresIn: number;
  now?: Date;
}

export function presignPut(o: PresignInput): string {
  // 20260726T101500Z — basic ISO 8601, no punctuation, no milliseconds.
  const amzDate = (o.now ?? new Date())
    .toISOString()
    .replace(/[-:]/g, "")
    .replace(/\.\d{3}/, "");
  const dateStamp = amzDate.slice(0, 8);
  const host = `${o.bucket}.s3.${o.region}.amazonaws.com`;
  const scope = `${dateStamp}/${o.region}/s3/aws4_request`;

  const query: Array<[string, string]> = [
    ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
    ["X-Amz-Credential", `${o.accessKeyId}/${scope}`],
    ["X-Amz-Date", amzDate],
    ["X-Amz-Expires", String(o.expiresIn)],
    ["X-Amz-SignedHeaders", "host"],
  ];
  if (o.sessionToken) query.push(["X-Amz-Security-Token", o.sessionToken]);
  query.sort(([a], [b]) => (a < b ? -1 : 1)); // byte order, not insertion order

  const canonicalQuery = query.map(([k, v]) => `${enc(k)}=${enc(v)}`).join("&");
  const canonicalUri = "/" + o.key.split("/").map(enc).join("/");

  // Blank line after the header block, then the signed-header list, then the
  // payload hash. Presigned S3 requests always use the UNSIGNED-PAYLOAD literal.
  const canonicalRequest = [
    "PUT",
    canonicalUri,
    canonicalQuery,
    `host:${host}\n`,
    "host",
    "UNSIGNED-PAYLOAD",
  ].join("\n");

  const stringToSign = [
    "AWS4-HMAC-SHA256",
    amzDate,
    scope,
    sha256Hex(canonicalRequest),
  ].join("\n");

  // Four nested HMACs: the signing key is scoped to one day, one region,
  // one service — so a leaked signing key expires on its own within 24 hours.
  const kDate = hmac(`AWS4${o.secretAccessKey}`, dateStamp);
  const kRegion = hmac(kDate, o.region);
  const kService = hmac(kRegion, "s3");
  const kSigning = hmac(kService, "aws4_request");
  const signature = hmac(kSigning, stringToSign).toString("hex");

  return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
}

console.log(
  presignPut({
    accessKeyId: "AKIAIOSFODNN7EXAMPLE",
    secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    region: "eu-west-1",
    bucket: "media-uploads",
    key: "user-uploads/2026/07/a1b2c3d4.png",
    expiresIn: 900,
    now: new Date("2026-07-26T10:15:00Z"),
  }),
);

Because every input is fixed, the output is deterministic — run it and you get exactly this signature:

X-Amz-Signature=f66d4daf38bb9c88d96d95365c988dc443a99c6b7d083d8b5742d6d8ed3fbed9

Two details in there cause most of the real-world mismatches. The canonical query string must be sorted by byte value of the encoded parameter name, and the path must be encoded segment by segment, so a key containing holiday photo (1).jpg becomes holiday%20photo%20%281%29.jpg while the slashes stay literal. Get either wrong and you produce a signature for a URL that is not the URL you sent.

What S3 checks, and in what order

When the PUT arrives, S3 runs a fixed sequence of checks. Knowing the order tells you which one failed from the status code alone, which matters because your client has to decide between “retry the same URL”, “ask for a new URL” and “stop and surface an error to the user”.

Order of validation on a presigned PUT and the error each stage returns S3 checks region and endpoint, then recomputes the signature, then the not-before time, then the expiry window, then credential validity, then IAM and bucket policy, before writing the object and returning an ETag. Each check has its own failure code — read it before you retry 1. Endpoint region correct? 2. HMAC recomputes? 3. Now ≥ X-Amz-Date? 4. Inside the expiry window? 5. Credential still live? 6. Policy allows PutObject? 400 AuthorizationHeaderMalformed signed for one region, sent to another 403 SignatureDoesNotMatch a signed header differs from what you sent 403 Signature not yet current signer clock ahead of S3 403 Request has expired re-sign; never retry the same URL 403 ExpiredToken STS session died before the URL did 403 AccessDenied prefix, size or SSE condition rejected it 200 OK + ETag object durable, ETag is the MD5 for a single PUT
Checks 3 and 4 are clock problems, check 5 is a credential problem, check 6 is a policy problem — three different fixes hiding behind one status code.

Note that every failure after the region check returns 403 with an XML body. The body is where the discrimination lives, and browsers can read it: fetch gives you await response.text() on a failed PUT as long as CORS lets the response through. Parsing the <Code> element out of that body is the difference between a client that recovers and a client that loops.

Step-by-step implementation

Step 1: Scope the credential that does the signing

A presigned URL can never grant more than the identity that signed it already has. Give the signing role exactly one action on exactly one prefix, and add the two condition keys most teams miss: s3:signatureAge, which caps how long any presigned URL from this identity may live regardless of what your code passes as expiresIn, and s3:x-amz-server-side-encryption, which rejects an unencrypted write.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SignedBrowserUploadsOnly",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::media-uploads/user-uploads/*",
      "Condition": {
        "Bool": { "aws:SecureTransport": "true" },
        "NumericLessThan": { "s3:signatureAge": 900000 },
        "StringEquals": { "s3:x-amz-server-side-encryption": "AES256" }
      }
    },
    {
      "Sid": "NoOverwritesOutsideTheInbox",
      "Effect": "Deny",
      "Action": "s3:PutObject",
      "NotResource": "arn:aws:s3:::media-uploads/user-uploads/*"
    }
  ]
}

s3:signatureAge is measured in milliseconds, so 900000 is 15 minutes. It is evaluated against the age of the signature at the moment the request lands, which makes it a hard ceiling you can enforce centrally even if a service somewhere signs a seven-day URL by accident.

Step 2: Validate before you sign, not after

The signing endpoint is the only place in this architecture where you get to say no. Once the URL exists, your Content-Type binding is the only server-side constraint left, and a determined client can still send bytes that do not match the declared type — which is why server-side file validation has to run after the object lands, not instead of this.

import { randomUUID } from "node:crypto";

const ALLOWED = new Map<string, string>([
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
  ["image/webp", "webp"],
  ["video/mp4", "mp4"],
]);

const MAX_BYTES = 512 * 1024 * 1024; // 512 MiB — matches the client-side gate

export interface UploadIntent {
  contentType: string;
  contentLength: number;
}

export interface PlannedUpload {
  key: string;
  contentType: string;
  contentLength: number;
}

export function planUpload(userId: string, intent: UploadIntent): PlannedUpload {
  const ext = ALLOWED.get(intent.contentType);
  if (!ext) {
    throw new Error(`unsupported_content_type:${intent.contentType}`);
  }
  if (!Number.isInteger(intent.contentLength) || intent.contentLength <= 0) {
    throw new Error("content_length_required");
  }
  if (intent.contentLength > MAX_BYTES) {
    throw new Error(`too_large:${intent.contentLength}`);
  }

  // The server owns the key. Never interpolate a client-supplied filename:
  // "../" and unicode look-alikes both escape the prefix you thought you had.
  const day = new Date().toISOString().slice(0, 10).replace(/-/g, "/");
  return {
    key: `user-uploads/${day}/${userId}/${randomUUID()}.${ext}`,
    contentType: intent.contentType,
    contentLength: intent.contentLength,
  };
}

The original filename still matters to your users, so keep it — but keep it in your database row next to the opaque key, not in the object path. That also stops one user’s upload from overwriting another’s, which a client-chosen key makes trivially easy.

Step 3: Sign the PutObject command

With the plan validated, signing is four lines. The rest of this function exists to make the URL’s constraints legible to the client and to your own logs.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// WHEN_REQUIRED stops the SDK adding a CRC32 checksum header that the browser
// would then have to reproduce. Without it, presigned PUTs fail with 403.
const s3 = new S3Client({
  region: process.env.AWS_REGION,
  requestChecksumCalculation: "WHEN_REQUIRED",
});

const EXPIRES_IN = 900; // 15 minutes, matching the s3:signatureAge ceiling

export interface SignedUpload {
  url: string;
  key: string;
  method: "PUT";
  headers: Record<string, string>;
  expiresAt: string;
}

export async function signUpload(plan: PlannedUpload): Promise<SignedUpload> {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: plan.key,
    ContentType: plan.contentType,
    ContentLength: plan.contentLength,
    ServerSideEncryption: "AES256",
    Metadata: { "uploaded-by": "web", "plan-version": "3" },
  });

  const url = await getSignedUrl(s3, command, { expiresIn: EXPIRES_IN });

  return {
    url,
    key: plan.key,
    method: "PUT",
    // Echo back exactly what the signature binds. A client that guesses
    // its own Content-Type will produce SignatureDoesNotMatch.
    headers: { "Content-Type": plan.contentType },
    expiresAt: new Date(Date.now() + EXPIRES_IN * 1000).toISOString(),
  };
}

ContentType and ContentLength become entries in X-Amz-SignedHeaders, so the browser must reproduce both. Metadata and ServerSideEncryption become x-amz-* headers, and the presigner hoists those into the query string — the browser sends nothing extra for them, and the object still lands with x-amz-meta-uploaded-by: web on it. That asymmetry is the single most useful thing to know about the SDK’s presigner: x-amz- headers ride in the URL; everything else becomes the client’s obligation.

Expected shape of the response body:

{
  "url": "https://media-uploads.s3.eu-west-1.amazonaws.com/user-uploads/2026/07/26/u_8813/1f0c…png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIA…&X-Amz-Date=20260726T101500Z&X-Amz-Expires=900&X-Amz-Security-Token=IQoJb3…&X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost&X-Amz-Signature=8d5f…",
  "key": "user-uploads/2026/07/26/u_8813/1f0c….png",
  "method": "PUT",
  "headers": { "Content-Type": "image/png" },
  "expiresAt": "2026-07-26T10:30:00.000Z"
}

Step 4: Upload from the browser and classify the failure

The client’s job is to send the bytes with the headers it was given, and — crucially — to tell the three kinds of failure apart. A 5xx is worth retrying on the same URL. An expired URL needs a new one. A SignatureDoesNotMatch will never succeed no matter how many times you try it, and retrying it just burns the user’s data allowance.

export interface SignedUpload {
  url: string;
  key: string;
  method: "PUT";
  headers: Record<string, string>;
  expiresAt: string;
}

const s3ErrorCode = (xml: string): string =>
  xml.match(/<Code>([^<]+)<\/Code>/)?.[1] ?? "Unknown";

export async function putToS3(
  file: File,
  signed: SignedUpload,
  signal?: AbortSignal,
): Promise<string> {
  // A URL already inside its last 10 seconds is not worth starting.
  if (Date.parse(signed.expiresAt) - Date.now() < 10_000) {
    throw new Error("REFRESH_URL");
  }

  const response = await fetch(signed.url, {
    method: "PUT",
    body: file,
    headers: signed.headers, // Content-Type only; the browser sets the length
    signal,
  });

  if (response.status === 403) {
    const code = s3ErrorCode(await response.text());
    // "Request has expired" and ExpiredToken are recoverable by re-signing.
    if (code === "AccessDenied" || code === "ExpiredToken") {
      throw new Error("REFRESH_URL");
    }
    throw new Error(`FATAL_${code}`); // SignatureDoesNotMatch, etc.
  }
  if (!response.ok) {
    throw new Error(`RETRYABLE_${response.status}`);
  }

  const etag = response.headers.get("ETag");
  if (!etag) {
    throw new Error("ETAG_NOT_EXPOSED"); // add ETag to the bucket's ExposeHeaders
  }
  return etag.replaceAll('"', "");
}

Wrap the RETRYABLE_* branch in the backoff policy described in implementing exponential backoff for failed chunks, and make the retry idempotent by keeping the same server-issued key across attempts — the same discipline as retrying fetch uploads with idempotency keys. Because the key is fixed, a duplicate PUT simply overwrites itself; you never end up with two half-objects.

Step 5: Close the loop with a commit call

S3 will not tell your backend that the object arrived. Either subscribe to s3:ObjectCreated:* events, or have the client POST the key and ETag back once the PUT returns 200 — most systems want both, because the client call is fast and the event is authoritative. Compare the ETag against a checksum you computed before the upload, using the technique in computing file checksums in the browser with Web Crypto, and you have end-to-end integrity rather than a hope that the transfer was clean.

Signing multipart part URLs

A single presigned PUT tops out at 5 GiB, and long before that the failure economics turn against you: one dropped connection at 90% costs the entire transfer. Above roughly 100 MB, switch to multipart — the threshold analysis is in multipart vs single-PUT for files under 100MB.

The pattern changes shape: CreateMultipartUpload and CompleteMultipartUpload stay on the server, because they need to be authorised once and their responses drive your state. Only UploadPart gets presigned, one URL per part.

import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  requestChecksumCalculation: "WHEN_REQUIRED",
});

export async function beginMultipart(
  key: string,
  contentType: string,
  partCount: number,
): Promise<{ uploadId: string; partUrls: string[] }> {
  const created = await s3.send(
    new CreateMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      ContentType: contentType,
      ServerSideEncryption: "AES256",
    }),
  );
  const uploadId = created.UploadId;
  if (!uploadId) throw new Error("no_upload_id_returned");

  // Part numbers are 1-based and must be contiguous at completion time.
  const partUrls = await Promise.all(
    Array.from({ length: partCount }, (_unused, i) =>
      getSignedUrl(
        s3,
        new UploadPartCommand({
          Bucket: process.env.S3_BUCKET,
          Key: key,
          UploadId: uploadId,
          PartNumber: i + 1,
        }),
        { expiresIn: 3600 }, // parts run long; 1 hour, not 15 minutes
      ),
    ),
  );

  return { uploadId, partUrls };
}

export async function finishMultipart(
  key: string,
  uploadId: string,
  etags: string[],
): Promise<string | undefined> {
  const result = await s3.send(
    new CompleteMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: {
        Parts: etags.map((etag, i) => ({ ETag: etag, PartNumber: i + 1 })),
      },
    }),
  );
  return result.ETag; // looks like "3858f62230ac3c915f300c664312c11f-8"
}

Note UploadPartCommand deliberately omits ContentLength — part sizes vary in the tail and binding a length you have not measured guarantees a mismatch on the final part. Every part except the last must be at least 5 MiB. And because an abandoned multipart upload leaves billable parts that never appear in a bucket listing, pair this with the rule described in expiring incomplete multipart uploads automatically.

Configuration reference

Option Type Default Effect
expiresIn number (seconds) 900 Becomes X-Amz-Expires. Hard maximum 604800; the SDK throws Signature version 4 presigned URLs must have an expiration date less than one week in the future above it.
signingRegion string client region Overrides the region in the credential scope. Set it when the client is configured for one region but the bucket lives in another.
signableHeaders Set<string> request headers Forces extra headers into X-Amz-SignedHeaders. Every one added becomes a header the browser must send byte-identically.
unhoistableHeaders Set<string> empty Keeps an x-amz-* header out of the query string, making it the client’s job to send. Rarely what you want for browser uploads.
requestChecksumCalculation "WHEN_SUPPORTED" | "WHEN_REQUIRED" WHEN_SUPPORTED On the S3Client. Leave at the default and the SDK folds a CRC32 checksum requirement into the signature that browsers cannot satisfy.
forcePathStyle boolean false On the S3Client. Required for bucket names containing dots, where the virtual-hosted wildcard certificate does not match.
ContentType string unset Signed header. Binds the declared MIME type; the client must send the identical string, casing included.
ContentLength number unset Signed header. The browser sets it automatically from the body, so it silently enforces an exact byte count.
ChecksumSHA256 base64 string unset Signed header x-amz-checksum-sha256. S3 rejects the write with BadDigest if the received bytes hash differently.
Metadata record {} Hoisted into the query string as x-amz-meta-*. Values must be US-ASCII; the total header block is capped at 2 KB.
s3:signatureAge IAM condition (ms) none Bucket- or role-level ceiling on how old a signature may be when used. Enforced by S3, not by your code.
s3:authType IAM condition none Set to REST-QUERY-STRING to allow only presigned access, or deny it to ban presigned URLs on a sensitive prefix.

Edge cases and gotchas

The credential dies before the URL does

This is the most common “it worked in staging” failure. When your signer runs on a role — Lambda, ECS, EKS — it signs with temporary STS credentials, and the URL carries an X-Amz-Security-Token. The signature stays mathematically valid for the full expiresIn, but S3 rejects it the moment the session token expires, with 403 ExpiredToken and the message The provided token has expired. A Lambda role session typically has under an hour left; a default AssumeRole session has exactly one.

Presigned URL lifetime against credential lifetime A one-hour presigned URL drawn against a session token with 22 minutes remaining; the usable window is the shorter of the two, and every request after minute 22 returns ExpiredToken. The usable window is the shorter of the two, always X-Amz-Expires=3600 — what you asked for 403 ExpiredToken from here onward STS session token — 22 minutes of life left Effective upload window 0 10 20 30 40 50 60 min Cap expiresIn at the credential's remaining lifetime minus 60 seconds, and surface the real deadline to the client.
Nothing in the SDK warns you about this — the URL is generated happily and dies silently 38 minutes early.

The fix is to read the credential’s own expiry and clamp:

import { S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  requestChecksumCalculation: "WHEN_REQUIRED",
});

export async function safeExpiry(requestedSeconds: number): Promise<number> {
  const credentials = await s3.config.credentials();
  if (!credentials.expiration) return requestedSeconds; // long-lived key
  const remaining =
    Math.floor((credentials.expiration.getTime() - Date.now()) / 1000) - 60;
  return Math.max(30, Math.min(requestedSeconds, remaining));
}

Clock skew produces a 403 that looks like a permissions bug

If the signing host’s clock is ahead of S3’s, the URL is not yet valid when the client uses it, and you get 403 with Signature not yet current: 20260726T101500Z is still later than 20260726T101243Z. This is common on long-lived VMs without NTP and on developer laptops resuming from sleep. Two mitigations: run chrony or the Amazon Time Sync Service on any host that signs, and back-date X-Amz-Date by 30 seconds in environments you do not control. The SDK already applies a skew correction after its first API response, but a presigner that never makes a real call has nothing to correct against.

Content-Length is a forbidden header in fetch

Setting "Content-Length" in a fetch request’s headers does nothing — it is on the forbidden header list and the browser silently drops it. This looks alarming when ContentLength is in your X-Amz-SignedHeaders, but it works out: the browser computes the real body length and sets the header itself. If the file is exactly the size you declared at signing time, the signature matches. If it is not — the user swapped the file after the URL was issued, or the client compressed it — you get SignatureDoesNotMatch, which is precisely the enforcement you wanted. XMLHttpRequest behaves the same way. If you need a size range rather than an exact match, the signature cannot express that; use a POST policy instead, as described in enforcing upload size limits with S3 POST policies and compared in presigned POST vs presigned PUT for browser uploads.

The preflight never carries the signature

A browser PUT with a custom Content-Type triggers an OPTIONS preflight, and that preflight is unsigned and anonymous. S3 answers it from the bucket’s CORS configuration alone — so a bucket policy that denies anonymous access does not break preflight, but a missing AllowedHeader does, and the signed PUT you spent all this effort on is never sent. The symptom in DevTools is a CORS error with no S3 status code at all. Work through fixing CORS preflight errors on S3 uploads before you suspect the signature.

{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["PUT"],
      "AllowedHeaders": ["Content-Type", "x-amz-checksum-sha256"],
      "ExposeHeaders": ["ETag", "x-amz-request-id", "x-amz-id-2"],
      "MaxAgeSeconds": 3000
    }
  ]
}

ExposeHeaders is not optional here. Without ETag in that list the header exists on the wire but response.headers.get("ETag") returns null, and multipart completion — which needs every part’s ETag — becomes impossible from the browser.

The ETag is not always an MD5

For a single PUT with no encryption or with SSE-S3 (AES256), the ETag is the hex MD5 of the object and you can compare it against a client-side digest. With SSE-KMS or SSE-C it is an opaque value, and for any multipart object it is the MD5 of the concatenated part MD5s followed by -<partCount>. Code that asserts etag === md5(file) therefore breaks the day someone enables KMS on the bucket. If you need a stable, comparable integrity value, sign ChecksumSHA256 instead and read it back with GetObjectAttributes — it survives encryption and works for multipart objects too.

Signed URLs leak through logs and referrers

The full URL, signature included, appears in CloudFront and ALB access logs, in browser history, in any Referer header, and in the console output of a well-meaning debug statement. Anyone who reads one within its window can replay it. Keep windows to 15 minutes, redact the X-Amz-Signature parameter in your own logging middleware, and never put a presigned URL in a query parameter of a page you render. For anything genuinely sensitive, an IAM condition on aws:SourceIp narrows replay to the issuing network — usable for server-to-server transfers, not for mobile clients whose IP changes mid-upload.

One URL, one key, one upload

Reissuing a fresh URL for the same key on every retry is fine and normal. Reissuing a fresh key is not: you get orphaned partial objects, duplicate rows, and a scanner queue processing the same photo four times. Keep the key in your upload record from the moment you plan it, and have the signing endpoint return the existing key when the client presents the same upload id. This also stops a retry storm from turning into a storage bill, and it pairs naturally with quarantine bucket patterns for infected uploads, where the key is the identity the scanner reports against.

Verification

Start by proving the signature binds what you think it binds. Sign a URL for image/png, then send text/plain — a 200 here means your content type is not in X-Amz-SignedHeaders and the whole constraint is decorative.

URL="$(node ./sign-one.mjs)"   # your Step 3 handler, printing just the url

# 1. Correct content type: expect 200 and an ETag header.
curl -s -o /dev/null -D - -X PUT "$URL" \
  -H 'Content-Type: image/png' --data-binary @fixture.png

# 2. Wrong content type on the same URL: expect 403 SignatureDoesNotMatch.
curl -s -X PUT "$URL" \
  -H 'Content-Type: text/plain' --data-binary @fixture.png | head -c 400

# 3. Tamper with one byte of the key path: expect 403 SignatureDoesNotMatch.
curl -s -X PUT "${URL/a1b2/a1b3}" \
  -H 'Content-Type: image/png' --data-binary @fixture.png | head -c 400

Case 2 returns a body that gives you the canonical request S3 built, which is the single most useful debugging artefact in this whole area:

<Error>
  <Code>SignatureDoesNotMatch</Code>
  <Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
  <CanonicalRequest>PUT
/user-uploads/2026/07/a1b2c3d4.png
X-Amz-Algorithm=AWS4-HMAC-SHA256&amp;X-Amz-Credential=AKIA…&amp;X-Amz-Date=20260726T101500Z&amp;X-Amz-Expires=900&amp;X-Amz-SignedHeaders=content-type%3Bhost
content-type:text/plain
host:media-uploads.s3.eu-west-1.amazonaws.com

content-type;host
UNSIGNED-PAYLOAD</CanonicalRequest>
</Error>

Diff that block against the one your own presignPut produces and the offending line is immediately obvious — it is almost always a content type, an unencoded space in the key, or a host that includes a port.

Next, prove the expiry actually expires:

# Sign with expiresIn: 5, then wait it out.
URL="$(EXPIRES_IN=5 node ./sign-one.mjs)"
sleep 8
curl -s -X PUT "$URL" -H 'Content-Type: image/png' --data-binary @fixture.png
# <Code>AccessDenied</Code><Message>Request has expired</Message>
# <X-Amz-Expires>5</X-Amz-Expires><Expires>...</Expires><ServerTime>...</ServerTime>

Compare Expires with ServerTime in that response: if they disagree by more than a couple of seconds beyond your window, your signer’s clock is drifting and the skew fix above applies.

Finally, assert the object landed the way you intended, including the metadata that rode in the query string:

aws s3api head-object --bucket media-uploads \
  --key 'user-uploads/2026/07/a1b2c3d4.png' \
  --query '{etag:ETag,type:ContentType,len:ContentLength,sse:ServerSideEncryption,meta:Metadata}'
# {
#   "etag": "\"9b2cf5f0c3a1e4d78f0b1c2d3e4f5a6b\"",
#   "type": "image/png",
#   "len": 184320,
#   "sse": "AES256",
#   "meta": { "uploaded-by": "web", "plan-version": "3" }
# }

If sse comes back null, your ServerSideEncryption parameter never made it into the signed URL and the IAM condition from Step 1 would have rejected the write — a passing upload with a missing sse field means the condition is not attached to the identity you think is signing.

Frequently Asked Questions

Does a presigned URL leak my credentials?

No. The URL contains your access key id and the derived HMAC, never the secret access key. Recovering the secret would mean reversing HMAC-SHA256 through a four-stage key derivation. What a leaked URL does expose is the single operation it was signed for, until it expires — which is why short windows matter more than URL secrecy.

Can I revoke a presigned URL after issuing it?

Not individually. Your options are all blunt: rotate or delete the signing credential, attach a bucket policy that denies the prefix, or wait out the window. Temporary credentials give you a middle path, since revoking the role session invalidates every URL it signed at once. Design for expiry rather than revocation.

Why does the same URL work in curl but fail in the browser?

Almost always CORS, not signing. curl sends no Origin header and skips preflight entirely, so it exercises a different code path in S3. If curl succeeds and the browser shows a network error with no status code, the OPTIONS request is being rejected by the bucket’s CORS rules.

Should the signing endpoint be authenticated?

Yes, and rate limited. An unauthenticated signing endpoint is an open write proxy into your bucket — the attacker never needs your credentials because your own server hands out write capability on request. Require a session, bind the key prefix to the user id, and cap issuance per identity.

Is presigning a network call to AWS?

No. Signing is pure local computation over your credentials, so it costs microseconds and cannot fail with a throttling error. That means you can sign thousands of part URLs in one request handler, and it also means AWS has no record that a URL exists until someone uses it.