Table of Contents
AWS S3 Presigned URLs: How to Generate and Use Signed URLs with Java (SDK v2)
Updated July 2026: the original version of this article (November 2021) used AWS SDK for Java v1. That SDK entered maintenance mode on July 31, 2024, and reached end-of-support on December 31, 2025 — it no longer receives patches or updates. I rewrote the entire article with SDK v2 (
S3Presigner), and added several duration and security gotchas the original version never covered.
In S3, every object is private by default. A presigned URL is how the owner of an object grants someone else temporary access to upload or download it — without sharing credentials, without touching IAM permissions, just a link with an expiration date.
It’s one of those primitives that looks trivial until you take it to production: the real duration depends on the type of credential that signed the URL (not just the parameter you configured), a presigned PUT won’t let you limit the size of the file someone uploads, and a leaked URL stays valid until you revoke the credential that generated it — there’s no individual “invalidate” button.
Let’s build it with AWS SDK v2 on top of a Quarkus endpoint, and cover the details that actually matter once this runs in production.
🎯 ProTip #1: A presigned URL is, for all practical purposes, a bearer token. Anyone with the link can use it — there’s no identity verification beyond the cryptographic signature. If you need to revoke it before it expires, the only way is to rotate or deactivate the credentials that signed it.
Generating a Presigned Download URL (GET)
With SDK v2, the class that generates signed URLs is S3Presigner — not S3Client directly:
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import java.time.Duration;
@Path("/files")
public class FileResource {
@GET
@Path("/{bucket}/{key}/download-url")
public Response getDownloadUrl(@PathParam("bucket") String bucket,
@PathParam("key") String key) {
try (S3Presigner presigner = S3Presigner.create()) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(15))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
return Response.ok(presignedRequest.url().toString()).build();
}
}
}
S3Presigner implements AutoCloseable — close it (or reuse a singleton instance via CDI) instead of creating one per request if your service handles real traffic.
Generating a Presigned Upload URL (PUT)
The flow is symmetric, swapping GetObjectRequest for PutObjectRequest:
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.util.Map;
@POST
@Path("/{bucket}/{key}/upload-url")
public Response getUploadUrl(@PathParam("bucket") String bucket,
@PathParam("key") String key) {
try (S3Presigner presigner = S3Presigner.create()) {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType("image/jpeg")
.metadata(Map.of("title", key))
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(putObjectRequest)
.build();
PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest);
return Response.ok(presignedRequest.url().toString()).build();
}
}
Whoever receives this URL uploads the file with a plain PUT request — no SDK, no AWS credentials, just any HttpClient:
HttpClient httpClient = HttpClient.newHttpClient();
httpClient.send(HttpRequest.newBuilder()
.uri(new URL(presignedUrlString).toURI())
.PUT(HttpRequest.BodyPublishers.ofFile(Path.of("photo.jpg")))
.build(), HttpResponse.BodyHandlers.discarding());
⚠️ ProTip #2: A presigned PUT won’t let you limit the size of the file the client uploads — S3 doesn’t validate
Content-Lengthserver-side even when you sign it as a header. The real limit is S3’s maximum for a simple PUT: 5 GB. If you need a real limit (5 MB for user avatars, say), the right tool is a Presigned POST with thecontent-length-rangecondition in the policy — not a PUT. With an important caveat for Java: keep reading.
How Long Does a Presigned URL Last?
The SDK lets you set up to 7 days of duration using AWS Signature Version 4 (from the S3 console, the maximum is 12 hours). But that duration you configure is only a ceiling — not a guarantee. If the URL was signed with temporary credentials, it stops working the moment those credentials expire, no matter what you set:
| Credential type | What determines the real expiration |
|---|---|
| IAM User (long-term) | The duration you set in the request, up to 7 days |
| IAM Role / STS AssumeRole | Ends when the role session expires — even if the URL claims to last longer |
| Instance profile (EC2, Lambda, ECS) | Ends when the instance’s or function’s temporary credentials expire |
| S3 Express (directory buckets) | Maximum 5 minutes — S3 Express credentials live for only 5 minutes |
🔍 ProTip #3: This is the gotcha that breaks presigned URLs in production the most. You generate the URL with
Duration.ofDays(1)from a Lambda function, but that Lambda’s execution role uses temporary credentials that expire in minutes or hours. The URL “lives” until that real expiration, not until whatever you configured insignatureDuration. Always check what type of credential is doing the signing, not just the request parameter.
The Presigned POST Gap in Java
If you actually need to limit the size of an uploaded file, the tool is a Presigned POST: instead of signing a single URL, you sign a policy with conditions — including content-length-range — and the client uploads the file via a multipart/form-data form.
The catch: AWS SDK for Java v2 still doesn’t have native support for generating presigned POST, unlike boto3 (Python), the JavaScript SDK, or the Go SDK. It’s a feature request open since 2019 still unresolved as of July 2026. The practical workarounds today are: implementing the policy signing by hand (not trivial, but documented), delegating that specific endpoint to a Lambda function in Python or Node.js, or accepting the PUT limit and validating size at your application layer after the upload.
Migrating from SDK v1 to SDK v2
If you still have code like the original version of this article (GeneratePresignedUrlRequest, TransferManagerBuilder), here’s the migration table:
| Concept | SDK v1 (EOL since Dec. 2025) | SDK v2 |
|---|---|---|
| Client | AmazonS3 / TransferManager |
S3Client / S3TransferManager |
| Generate URL | s3Client.generatePresignedUrl(...) |
presigner.presignGetObject(...) / presignPutObject(...) |
| Signing request | GeneratePresignedUrlRequest |
GetObjectPresignRequest / PutObjectPresignRequest |
| Duration | .withExpiration(Date) |
.signatureDuration(Duration) |
| Base package | com.amazonaws.* |
software.amazon.awssdk.* |
| Exceptions | AmazonServiceException |
S3Exception (extends AwsServiceException) |
Security: Beyond Expiration
- It’s a bearer token. Anyone with the link can use it. There’s no additional identity verification — security depends entirely on the link not leaking and on the duration you configured.
- Restrict by IP if the use case allows it. An
aws:SourceIpcondition in the IAM or bucket policy limits which IP ranges the presigned URL is valid from, even if the link leaks outside those ranges. - Sanitize the object key. If the key comes from user input, reject sequences like
../before building theGetObjectRequest— without that validation, a malicious key can try to reach outside the expected prefix. - There’s no individual revocation. If a presigned URL leaked and you need to invalidate it before its natural expiration, the only real lever is rotating or deactivating the credentials that signed it — which will likely also invalidate other URLs generated with that same credential.
My Take
Presigned URLs are still, five years after the original version of this article, the right way to grant temporary access to an S3 object without exposing credentials — that hasn’t changed. What changed is the API surface (SDK v1 is dead) and what I didn’t cover the first time around: the real duration isn’t what you configure, it’s what the signing credential allows; and if you need a real size limit on uploads, PUT isn’t the tool — and in Java, POST isn’t natively either, not yet.
Are you migrating code off SDK v1, or implementing presigned URLs for the first time? Tell me what gotcha you ran into — comments are open.
Start the conversation