S3 presigned URLs in depth
Upload links, not just downloads — plus the expiry and credential-lifetime traps the basic example skips. Presigned S3 URLs from Delphi, properly.
Contents
The last time presigned URLs came up they were one button on a desktop app: pick an object, get a link, send it to someone. Five lines, and the S3 application post moved on.
That covered downloads. It left out the links people upload to, and said nothing about the two things that decide whether either kind holds up in production: how long a URL actually lives, and where you generate it.
The download link, recapped
A presigned URL is a short-lived link with the credentials for one specific operation baked into the query string. Hand it to someone and they can perform that one operation, no AWS account required. When it expires, it stops working.
The download case builds a GetObject request, wraps it in a presigner request,
sets an expiry, and asks the presigner for the URL:
uses
AWS.S3, AWS.Types;
var
GetRequest: IS3GetObjectRequest;
Presigner: IS3Presigner;
PresignerRequest: IS3PresignerRequest;
begin
GetRequest := TS3GetObjectRequest.Create('my-bucket', 'reports/august.pdf');
Presigner := TS3Presigner.Create;
PresignerRequest := TS3PresignerRequest.Create(GetRequest);
PresignerRequest.ExpiresIn := 3600; // seconds; one hour
Writeln(Presigner.PresignedUrl(PresignerRequest));
end;
Build a request, wrap it, sign it, hand out the string. The upload case uses the same four calls with one type swapped, plus a property the download case never needs.
Upload URLs, not just downloads
The same mechanism works in the other direction. A presigned PUT lets someone
send you a file without an AWS account, without credentials, and without the
bytes passing through your application.
Swap the GetObject request for a PutObject request naming the key you want
written, and presign it the same way:
uses
AWS.S3, AWS.Types;
var
PutRequest: IS3PutObjectRequest;
Presigner: IS3Presigner;
PresignerRequest: IS3PresignerRequest;
begin
PutRequest := TS3PutObjectRequest.Create('my-bucket', 'uploads/photo.jpg');
Presigner := TS3Presigner.Create;
PresignerRequest := TS3PresignerRequest.Create(PutRequest);
PresignerRequest.ExpiresIn := 900; // seconds; fifteen minutes
PresignerRequest.SignedPayload := False;
Writeln(Presigner.PresignedUrl(PresignerRequest));
end;
SignedPayload := False is what makes the upload URL work, and the download case
never needs it. By default the signature covers a hash of the request body. That
holds when you're signing a request you're about to send yourself, because you
already have the bytes. When you're signing on someone else's behalf, for a file
that doesn't exist yet, there's nothing to hash. Setting it false signs the
request without committing to a payload, so whoever holds the URL can send
whatever they're uploading. Leave it at the default and the URL only accepts a
body matching a hash you had no way to compute.
The URL that comes back is an upload link. Whoever holds it sends the file with
an HTTP PUT to that address:
curl --upload-file photo.jpg "https://my-bucket.s3.eu-west-1.amazonaws.com/uploads/photo.jpg?X-Amz-Algorithm=..."
The file lands at uploads/photo.jpg in the bucket. A browser fetch with
method: 'PUT' does the same job from a web page. No SDK on the other end, no
keys shared, and nothing to relay through your own server.
What the URL constrains, and what it doesn't
A presigned URL is bound to the exact operation you signed: this bucket, this
key, this method, this expiry. A URL presigned for PUT uploads/photo.jpg can
only write that one key. The holder can't rename it to uploads/invoice.pdf,
can't turn a PUT into a DELETE, and can't reach a different bucket. Change any signed part
of the request and the signature no longer matches, so S3 rejects it.
What it does not do is cap the size or vet the contents. A presigned PUT will
accept a one-byte file or a five-gigabyte one at the same key. If you need an
upper bound on what someone can upload, a presigned PUT is the wrong tool.
That's what S3's browser-based POST policies exist for, with their
content-length-range condition. Reach for those when the size limit is the
point.
The URL is the credential. Anyone who has it can perform the operation until it expires. It's a bearer token. Treat it like one: short expiries, HTTPS only, and don't log it or drop it into a URL that ends up in a browser history you don't control.
Expiry, and the credential-lifetime trap
ExpiresIn looks like it sets how long the URL lives. It sets a ceiling. The
real lifetime is the shorter of ExpiresIn and however long the credentials
that signed the URL remain valid.
That distinction never shows up in development, because a developer machine is
usually signing with long-term IAM user access keys, resolved through the chain
covered in the credentials post.
Those don't expire, so an ExpiresIn of one hour gives you a URL good for one
hour, and seven days (604800, the SigV4 maximum) gives you seven days.
Move the same code to a server and it changes. A server that authenticates
through an IAM role (an EC2 instance profile, an ECS task role, anything backed
by STS) is signing with temporary credentials, and those expire, often after
an hour. Sign a URL with ExpiresIn := 604800 there and it still dies when the
underlying credentials do. The seven days you asked for silently become the
forty minutes left on the session.
A presigned URL can't outlive the credentials that signed it. If you need
long-lived URLs, sign with credentials that live at least as long. On a
role-based server, assume the URLs are short-lived, keep ExpiresIn modest, and
generate them on demand rather than minting one and storing it.
One region note carries over from the S3 application post: the endpoint is part of the signed URL, so the client that signs has to be pointed at the bucket's region. Sign against the wrong region and the URL fails with a signature error before the expiry ever matters.
Issuing URLs from a service
The desktop app in the earlier post held AWS credentials itself and signed its own links. That's fine for an internal tool where everyone running it is trusted with the keys anyway. It's the wrong model the moment the thing asking for a URL is a phone, a browser, or a customer.
You don't ship AWS credentials to clients. Instead a small service you control
holds the credentials and hands out presigned URLs on request. A client says "I
want to upload photo.jpg," the service signs a PUT URL for a key it chooses,
and the client uploads straight to S3. The credentials never leave your server;
the file never touches it.
In Delphi that service is the same handful of lines wrapped in a function, with the AWS SDK for Delphi doing the signing:
uses
AWS.S3, AWS.Types;
function UploadUrlFor(const Key: string): string;
var
PutRequest: IS3PutObjectRequest;
Presigner: IS3Presigner;
PresignerRequest: IS3PresignerRequest;
begin
PutRequest := TS3PutObjectRequest.Create('my-bucket', Key);
Presigner := TS3Presigner.Create;
PresignerRequest := TS3PresignerRequest.Create(PutRequest);
PresignerRequest.ExpiresIn := 900;
PresignerRequest.SignedPayload := False;
Result := Presigner.PresignedUrl(PresignerRequest);
end;
Have your endpoint choose the key rather than accepting one from the caller: prefix it with the authenticated user's id, add a random component, pin the extension. That stops a client presigning its way over someone else's object. The signing stays trivial. The judgement is all in which key you're willing to sign, and for whom.
More in this series
Presigned URLs are one of the SDK's higher-level helpers: types that sit above the raw API calls and take care of the tedious part. The others get the same treatment here as they come up.
More posts
Backblaze B2 from Delphi
Point the AWS SDK for Delphi at Backblaze B2: the application key its S3 API accepts, one region per account, always-on versioning, and a worked example.
Read more →
Self-hosted S3-compatible storage from Delphi
Run your own S3 endpoint and point the AWS SDK for Delphi at it: MinIO, RustFS, and SeaweedFS, one program against all three, and where MinIO's archival leaves things.
Read more →
Build ReadWhatYouSee: Textract, Translate, and Polly together
The series finale: a Delphi app that reads text from an image with Textract, translates it with Translate, and speaks it aloud with Polly.
Read more →