Testing AWS S3 presigned URLs on localhost with Yulin
Applications which use presigned S3 URLs can be awkward to test. The URL is just a string, so a test might only check that it looks about right.
It would be better if tests could verify the signature, and confirm that the signing principal is allowed to perform the action enabled by the presigned URL. Checking that often means deploying a real S3 Bucket, IAM credentials and so on to confirm that it works as expected.
Simulated S3 in Yulin serves a REST
endpoint on localhost which accepts presigned URLs built by the real AWS presigner (getSignedUrl
from @aws-sdk/s3-request-presigner). The signing itself is real rather than simulated: an ordinary
S3Client is pointed at the simulated endpoint and signs as it would against real S3, while
simulated IAM verifies the signature it produced.
The presigner is a separate package from the S3 client, so it needs to be installed alongside it:
npm install --save-dev @aws-sdk/s3-request-presigner
Here’s a simulated Bucket with one Object in it, a User allowed to read that Object, and a presigned URL fetched over HTTP:
import {
CreateAccessKeyCommand,
CreateUserCommand,
PutUserPolicyCommand,
} from "@aws-sdk/client-iam";
import {
CreateBucketCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { SimAws } from "@kensio/yulin";
import { serveSimAws } from "@kensio/yulin/serve";
const simAws = new SimAws({ defaultAccountId: "111111111111" });
const srv = await serveSimAws({ simAws });
const simS3 = simAws.region("eu-west-2").s3();
const simIam = simAws.iam();
await simS3.createBucket(new CreateBucketCommand({ Bucket: "foo-bucket" }));
await simS3.putObject(
new PutObjectCommand({
Bucket: "foo-bucket",
Key: "reports/foobar.txt",
Body: "foobar",
ContentType: "text/plain",
}),
);
await simIam.createUser(new CreateUserCommand({ UserName: "FooPublisher" }));
await simIam.putUserPolicy(
new PutUserPolicyCommand({
UserName: "FooPublisher",
PolicyName: "ReadFooReports",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::foo-bucket/reports/*",
},
}),
}),
);
const accessKey = await simIam.createAccessKey(
new CreateAccessKeyCommand({ UserName: "FooPublisher" }),
);
const credentials = {
accessKeyId: accessKey.AccessKey.AccessKeyId,
secretAccessKey: accessKey.AccessKey.SecretAccessKey,
};
const s3Client = new S3Client({
region: "eu-west-2",
endpoint: srv.localUrl(simS3.getServiceUrl()).toString(),
credentials,
});
const url = await getSignedUrl(
s3Client,
new GetObjectCommand({ Bucket: "foo-bucket", Key: "reports/foobar.txt" }),
{ expiresIn: 900 },
);
const response = await fetch(url);
console.log(response.status);
console.log(response.headers.get("x-sim-aws-caller"));
console.log(await response.text());
srv.close();
200
arn:aws:iam::111111111111:user/FooPublisher
foobar
An S3 presigned URL authorises one specific S3 request using the credentials of the principal that generated it, and it cannot grant permissions beyond that principal’s effective permissions.
Simulated IAM resolves that principal from the signature and authorises s3:GetObject. The
x-sim-aws-caller response header reports which principal that was.
This prevents privilege escalation, so a User without the permission can’t presign around it.
Editing the key in the URL to name a different Object gets a 403 with SignatureDoesNotMatch,
because the key is part of what was signed.
That builds on simulated IAM authorisation of S3 calls, with the caller arriving in a signature rather than being named by the test.
A presigned URL signs its own host, port included, and can’t be redirected somewhere else
afterwards, so the client has to sign for the URL you are actually going to call. That’s what
srv.localUrl(simS3.getServiceUrl()) gives you, and the URL it produces looks like this:
http://foo-bucket.s3.eu-west-2.sim-aws.localhost:64108/reports/foobar.txt?X-Amz-Algorithm=...
Expiry is judged against the simulation clock rather than the host’s. Freezing the clock keeps a URL usable however long a test spends, and advancing past the window expires it. This mechanism is handy for testing behaviours around presigned URL expiry.
simAws.clock().freeze();
const url = await getSignedUrl(
s3Client,
new GetObjectCommand({ Bucket: "foo-bucket", Key: "reports/foobar.txt" }),
{ expiresIn: 900 },
);
await simAws.clock().advanceBy({ minutes: 20 });
const expired = await fetch(url);
console.log(expired.status);
console.log(expired.headers.get("x-sim-aws-error"));
console.log(expired.headers.get("x-sim-aws-error-detail"));
403
AccessDenied
Request has expired. The presigned URL was signed at 2026-07-28T17:10:26.000Z
for 900 seconds, so it expired at 2026-07-28T17:25:26.000Z, and simulated time
is now 2026-07-28T17:30:26.419Z.
Those are similar to the AccessDenied and Request has expired answers that real S3 gives, so a
test can assert on the same error it would get from the real service. It’s the same
simulated clock control
that expires a temporary session.
Note that the signing itself uses the host clock, because it happens inside the real AWS SDK, so a
URL signed while the simulation is set to some other time can arrive already expired. The presigner
takes a signingDate for cases like this, and simAws.now() is the simulation’s own clock time:
const url = await getSignedUrl(s3Client, command, {
expiresIn: 900,
signingDate: simAws.now(),
});
The two clocks then agree on when the window opened, whatever the simulation is set to, and the URL still expires once simulated time passes it.
Presigned PutObjectCommand URLs work in the same way, with one thing to note. The AWS SDK
computes a checksum when it presigns, which is before there is a body to hash, and hoists it into
the signed URL where the signature covers it. Uploading anything else through that URL then fails,
both in the simulator and against real S3.
Building the client with requestChecksumCalculation: "WHEN_REQUIRED" presigns upload URLs which
accept a body:
const uploadClient = new S3Client({
region: "eu-west-2",
endpoint: srv.localUrl(simS3.getServiceUrl()).toString(),
requestChecksumCalculation: "WHEN_REQUIRED",
credentials,
});
const uploadUrl = await getSignedUrl(
uploadClient,
new PutObjectCommand({ Bucket: "foo-bucket", Key: "reports/upload.txt" }),
{ expiresIn: 900 },
);
const uploaded = await fetch(uploadUrl, {
method: "PUT",
body: "foobar",
headers: { "content-type": "text/plain" },
});
Without that setting the upload is refused with a 400 and XAmzContentChecksumMismatch, which is
what real S3 does too. The simulator says which setting avoids it:
The CRC32 checksum of the uploaded body is nvYflQ==, not the AAAAAA== the
request states. An AWS SDK presigned PUT states the checksum of an empty body
unless the client is built with requestChecksumCalculation: "WHEN_REQUIRED".
The REST endpoint serves GET, HEAD and PUT of an Object, so Bucket operations and multipart
uploads are refused with a 501 rather than answered. createPresignedPost is not yet simulated
either, and an upload stating a CRC32C or CRC64NVME checksum is refused rather than stored
unchecked.
Testing this needs no AWS account, and it runs in the same Node.js process as the rest of the test suite. That allows you to step through the system in a debugger, and also to collect test coverage metrics around S3 presigned URL behaviours.