Generating Secure Presigned URLs with AWS SDK v3
Pair @aws-sdk/client-s3 with @aws-sdk/s3-request-presigner, sign a PutObjectCommand with an explicit signableHeaders set and a 900-second expiresIn, and turn the SDK’s default request checksum off — that last step is what stops every browser upload returning 403 SignatureDoesNotMatch on SDK versions from 3.729.0 onwards.
When to use this approach
- A single object, a key your server chose, one attempt. A signed
PUTis the cheapest possible issue-and-forget handshake. If you need the browser to obey a size range or an upload policy with several conditions, sign a POST policy instead — the trade-offs are laid out in presigned POST vs presigned PUT for browser uploads. - You have somewhere to run Node with AWS credentials. Signing needs a resolved credential, so it belongs in an authenticated API route, not in the browser and not in an edge function that has no role attached.
- You are prepared to validate after the fact. Nothing in the signed URL inspects bytes. If validation has to happen before the object lands, route the upload through your own process instead and accept the memory cost, as compared in presigned URL vs server proxy tradeoffs.
Prerequisites
- Node 20 or later. Every snippet here is ESM with top-level
await. npm i @aws-sdk/client-s3@^3.750.0 @aws-sdk/s3-request-presigner@^3.750.0. Keep the two packages on the same minor version; the presigner reaches into the client’s middleware stack and a version drift produces confusingTypeError: middlewareStack.clone is not a functionfailures.AWS_REGIONandS3_BUCKETin the environment, plus a credential the default provider chain can find (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, an SSO profile, or an attached task role).- A signing identity scoped to exactly the prefix you write to. Anything the role can do, the URL can do:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SignIncomingUploadsOnly",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::media-intake-prod/incoming/*"
}
]
}
- A CORS rule on the bucket that allows
PUTfrom your origin and exposesETag. If the browser never reaches the signature, the problem is upstream — work through fixing CORS preflight errors on S3 uploads first.
How getSignedUrl builds the URL
getSignedUrl is not a network operation. It clones the client’s middleware stack, runs the command through serialization to get a fully-formed HTTP request, swaps the real signer for the SigV4 presigner, and returns the resulting URL string. No PutObject call reaches AWS, nothing is logged in CloudTrail, and a 100-request burst costs you microseconds of CPU rather than an API round trip — which is exactly why the endpoint that issues them needs its own budget, covered in rate limiting presigned URL issuance.
The presigner differs from the normal signer in one structural way: it hoists. Every x-amz-* header the serializer produced is moved out of the header block and into the query string, then the whole request is signed with the payload hash fixed to UNSIGNED-PAYLOAD. That is why Metadata: { "uploaded-by": "u_42" } shows up as a x-amz-meta-uploaded-by=u_42 query parameter and the browser has to send nothing extra for it. The arithmetic of the canonical request itself is identical to any other SigV4 signature and is derived step by step on the parent guide, S3 presigned URL workflows.
Implementation
One module, one export, no framework assumptions. It validates before it signs, because a signed URL is a capability and you cannot revoke one.
// presign.ts — issues one scoped, short-lived PUT URL per upload attempt.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const EXTENSIONS = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["video/mp4", "mp4"],
]);
const MAX_BYTES = 50 * 1024 * 1024;
const TTL_SECONDS = 900;
// One client per process: credential resolution, and the IMDS or STS call
// behind it, is cached on the instance rather than repeated per request.
const s3 = new S3Client({
region: process.env.AWS_REGION ?? "eu-west-1",
maxAttempts: 3,
// From v3.729.0 the SDK attaches a CRC32 checksum to PutObject by default.
// A browser will never reproduce it, so presigning requires WHEN_REQUIRED.
requestChecksumCalculation: "WHEN_REQUIRED",
});
export interface PresignRequest {
userId: string;
contentType: string;
contentLength: number;
}
export interface PresignResult {
url: string;
key: string;
expiresAt: string;
requiredHeaders: Record<string, string>;
}
export async function presignUpload(req: PresignRequest): Promise<PresignResult> {
const extension = EXTENSIONS.get(req.contentType);
if (!extension) {
throw new Error(`unsupported content type: ${req.contentType}`);
}
if (!Number.isInteger(req.contentLength) || req.contentLength <= 0) {
throw new Error("contentLength must be a positive integer");
}
if (req.contentLength > MAX_BYTES) {
throw new Error(`declared size ${req.contentLength} exceeds ${MAX_BYTES}`);
}
const key = `incoming/${req.userId}/${randomUUID()}.${extension}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: req.contentType,
Metadata: {
"uploaded-by": req.userId,
"declared-bytes": String(req.contentLength),
},
});
const url = await getSignedUrl(s3, command, {
expiresIn: TTL_SECONDS,
// Without this the content type is decorative: S3 accepts any bytes
// under any type, because nothing binds the header to the signature.
signableHeaders: new Set(["content-type"]),
});
return {
url,
key,
expiresAt: new Date(Date.now() + TTL_SECONDS * 1000).toISOString(),
requiredHeaders: { "content-type": req.contentType },
};
}
Line-by-line: the options that actually bind the URL
signableHeaders: new Set(["content-type"]) is the load-bearing line. Leave it out and ContentType on the command is a hint the SDK would have sent on a direct call and nothing more — S3 will happily store an executable under image/png. With it present, content-type joins host in X-Amz-SignedHeaders, and the browser’s header has to match byte for byte, lower-cased name and all. Image/PNG is a different string to image/png as far as the canonical request is concerned.
Metadata is hoisted, not signed, so the values are visible in the URL and readable by anyone who sees it in a log line. Put a user id there for reconciliation; do not put an email address, an internal customer identifier, or anything you would not paste into a Slack channel. The declared-bytes entry is a breadcrumb for the post-upload reconciliation job, not an enforcement mechanism — the signed PUT does not check length, and the way to make it check is a POST policy.
The returned requiredHeaders map exists so the client never has to guess. Ship it alongside the URL and have the upload code spread it into fetch, rather than reconstructing the content type on the browser side from file.type — those two values diverge more often than you would expect, and why browser MIME types are unreliable explains where the drift comes from.
Signing options reference
getSignedUrl(client, command, options) accepts a small set of options, and three of them change security behaviour rather than convenience.
| Option | Type | Default | Effect |
|---|---|---|---|
expiresIn |
number (seconds) | 900 |
Written to X-Amz-Expires. Values above 604800 throw before a URL is produced. |
signableHeaders |
Set<string> |
host only |
Lower-cased header names folded into the signature. The client must resend each one identically. |
unsignableHeaders |
Set<string> |
empty | Names explicitly excluded from the signature even if present on the serialized request. |
unhoistableHeaders |
Set<string> |
empty | x-amz-* names kept as real headers instead of being moved into the query string. |
signingDate |
Date |
new Date() |
Pins X-Amz-Date. Useful for deterministic tests; dangerous in production. |
signingRegion |
string | client region | Overrides the SigV4 scope, needed for multi-region access points. |
signingService |
string | "s3" |
Change only when signing for an S3-compatible service that expects a different scope. |
Client-level options matter just as much, because the signature covers the host the client would have called:
| Client option | Default | Why it changes the signature |
|---|---|---|
region |
none | Appears in the credential scope. A mismatch with the bucket produces 400 AuthorizationHeaderMalformed, not a 403. |
endpoint |
AWS regional | Signing against MinIO, Cloudflare R2 or a VPC endpoint changes the host in the canonical request. |
forcePathStyle |
false |
Switches between bucket.s3.host and s3.host/bucket. The two sign differently. |
useAccelerateEndpoint |
false |
Signs against s3-accelerate.amazonaws.com; the browser must use that host too. |
requestChecksumCalculation |
"WHEN_SUPPORTED" |
Adds a CRC32 header the browser cannot reproduce. Set "WHEN_REQUIRED" for presigning. |
The expiry you ask for is not the expiry you get
expiresIn is a ceiling request, not a guarantee. A presigned URL is only valid while the credential that signed it is still valid, and AWS enforces the shorter of the two. Sign a 6-hour URL with a role session that has 40 minutes left and the URL is dead in 40 minutes, with an error that names the token rather than the expiry.
The practical rule: keep expiresIn at or below 900 seconds and give the client a cheap way to ask for a fresh URL. That also limits blast radius if the URL leaks through a proxy log or a Referer header. For long transfers, sign per part rather than stretching the TTL — multipart vs single-PUT for files under 100MB covers where that boundary sits, and any parts that never complete should be swept up by expiring incomplete multipart uploads automatically.
Configuration gotchas
The default checksum turns every presigned PUT into a 403
Symptom, on any SDK from 3.729.0 onwards, on a URL that worked last month:
SignatureDoesNotMatchThe request signature we
calculated does not match the signature you provided. Check your key and
signing method.
The SDK’s default integrity protections attach x-amz-sdk-checksum-algorithm: CRC32 and a x-amz-checksum-crc32 value to PutObject. During presigning the body does not exist yet, so the checksum is computed over nothing, and the browser’s real body can never satisfy it. Detect it in one line rather than guessing:
node -p "new URL(process.argv[1]).search.includes('checksum')" "$SIGNED_URL"
If that prints true, set requestChecksumCalculation: "WHEN_REQUIRED" on the S3Client as in the module above, or export AWS_REQUEST_CHECKSUM_CALCULATION=when_required for the whole process. If you genuinely want end-to-end integrity, compute the digest in the browser instead and compare it after the fact — see computing file checksums in the browser with Web Crypto.
Could not load credentials from any providers
getSignedUrl resolves credentials lazily, so a misconfigured environment fails at signing time with:
CredentialsProviderError: Could not load credentials from any providers
at SignatureV4.signRequest
On Lambda this almost always means the execution role was replaced during a deploy; in a container it usually means AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is unset because the task definition lost its task role. Fail the health check on it rather than letting the upload endpoint 500: call await s3.config.credentials() once at boot and log the accessKeyId prefix.
A signature valid for the wrong host
Presigning against MinIO, R2 or a VPC endpoint without setting both endpoint and forcePathStyle produces a URL whose canonical request names bucket.localhost:9000 while the browser dials localhost:9000/bucket. S3-compatible servers return the same SignatureDoesNotMatch body as AWS, which sends people hunting through header names for hours. Print new URL(url).host next to the host your client will actually use; if the strings differ, the signature was never going to match.
An expiry the SDK refuses outright
Asking for more than a week throws synchronously, before any URL exists:
Error: Signature version 4 presigned URLs must have an expiration date less
than one week in the future
This is a client-side check in the signer, so no request is wasted — but it does mean a config value read from an environment variable can take down URL issuance at boot. Clamp it: Math.min(Number(process.env.UPLOAD_TTL ?? 900), 3600).
Classifying a 403 without guessing
Every failure above surfaces as a 403 with an XML body, and the Code element is the only thing that separates a signing bug from a permissions bug from an expiry. Read it before changing any code.
Verification
The useful test is not “does a URL come back” but “does the URL reject what it should reject”. This script asserts both directions against a real bucket and finishes in under a second.
// verify.ts — run with: node --experimental-strip-types verify.ts
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import { presignUpload } from "./presign.ts";
const body = readFileSync("fixture.png");
const { url, key, requiredHeaders } = await presignUpload({
userId: "u_42",
contentType: "image/png",
contentLength: body.byteLength,
});
const params = new URL(url).searchParams;
const signed = params.get("X-Amz-SignedHeaders") ?? "";
assert.ok(signed.includes("content-type"), `content-type not bound: ${signed}`);
assert.ok(!signed.includes("checksum"), `checksum leaked into signature: ${signed}`);
assert.equal(params.get("X-Amz-Expires"), "900");
const good = await fetch(url, { method: "PUT", headers: requiredHeaders, body });
assert.equal(good.status, 200, await good.text());
console.log(`stored ${key} with ETag ${good.headers.get("etag")}`);
const bad = await fetch(url, {
method: "PUT",
headers: { "content-type": "text/plain" },
body,
});
assert.equal(bad.status, 403);
assert.match(await bad.text(), /SignatureDoesNotMatch/);
console.log("wrong content-type correctly rejected");
Expected output:
stored incoming/u_42/6f1c9d0e-4b77-4a1e-9c30-8d21f0a4b3a1.png with ETag "9b2cf5f0c3a1e4d78f0b1c2d3e4f5a6b"
wrong content-type correctly rejected
A 200 on the second request means signableHeaders was dropped somewhere between your module and the deployed build. Wire the same two assertions into CI against a throwaway bucket — it is the only check that catches an SDK upgrade silently unbinding the content type. From the browser side, pair the upload with a timeout so a stalled connection does not sit on a URL until it expires; aborting uploads with AbortController and timeouts has the pattern, and retrying fetch uploads with idempotency keys covers what to do when the retry needs a fresh signature.
Once the object lands, the signed URL has done its job and everything else is a downstream concern of backend validation and cloud storage architecture: inspect the real bytes with validating file signatures with libmagic in Node.js, then promote or quarantine.
Frequently Asked Questions
Does getSignedUrl make a network call to AWS?
The signing itself does not — it is HMAC arithmetic over a string the SDK builds locally. The first call may still hit the network to resolve credentials from IMDS, STS or SSO, which is why you should construct the S3Client once at module scope and let it cache.
Can one presigned URL accept whatever file the user picks?
No. The object key, the bucket and any signed headers are frozen at signing time, so a URL is good for exactly one object at one path. Issue a URL per file after the user has selected it, which also lets you reject the content type before signing rather than after uploading.
Why does the same code produce a working URL locally and a 403 in production?
Locally you are almost certainly signing with a long-lived IAM user key; in production a task role hands you a rotating session credential, so the URL inherits X-Amz-Security-Token and dies when that token does. Check for ExpiredToken in the response body rather than assuming a header mismatch.
Can I presign a download the same way?
Yes — pass a GetObjectCommand to the same getSignedUrl call. Add ResponseContentDisposition: 'attachment; filename="report.pdf"' to the command and it is hoisted into the query string, so the browser gets a download prompt without you proxying a byte.
Does the URL work outside a browser?
It is ordinary HTTP, so curl, a mobile client and a server-side job all work. CORS never applies to those callers, which is worth remembering when a native app succeeds against a bucket whose CORS rules would block your web front end.