Richard Hatherall By Richard Hatherall 5 min read aws-sdk aws s3 delphi tutorial

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.

S3 presigned URLs in depth
Contents
  1. The download link, recapped
  2. Upload URLs, not just downloads
  3. What the URL constrains, and what it doesn't
  4. Expiry, and the credential-lifetime trap
  5. Issuing URLs from a service
  6. More in this series

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.

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.