Full Stack • Java • System Design • Cloud • AI Engineering

S3 Presigned URLs with Spring Boot

Learn how to generate Amazon S3 presigned URLs in Spring Boot step by step using AWS SDK for Java, secure file upload, secure file download, IAM permissions, input/output examples, and production best practices.


Introduction

In the previous article, we integrated Spring Boot with Amazon S3 for file upload, download, delete, and list operations.

In this article, we will learn about S3 Presigned URLs.

A presigned URL is a temporary secure URL that allows a user to upload or download a private S3 object without making the bucket public.

This is very useful for real-time applications like:

  • Profile image upload
  • Resume upload
  • PDF download
  • Invoice download
  • Report sharing
  • Secure document access
  • Direct frontend-to-S3 upload

What You Will Learn

  • What is an S3 presigned URL?
  • Why use presigned URLs?
  • GET presigned URL for download
  • PUT presigned URL for upload
  • Spring Boot implementation
  • AWS SDK for Java 2.x setup
  • Input and output examples
  • IAM permissions
  • Common errors
  • Production best practices

What is a Presigned URL?

A presigned URL is a temporary URL generated using AWS credentials.

It gives limited-time access to a specific S3 object.

Example:

https://bucket-name.s3.amazonaws.com/uploads/file.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600

This URL can expire in:

5 minutes
10 minutes
1 hour

After expiration, the URL will no longer work.


Why Use Presigned URLs?

Without presigned URLs:

User → Spring Boot → S3 → Spring Boot → User

With presigned URLs:

User → Spring Boot gets URL
User → S3 directly using URL

Benefits:

  • S3 bucket remains private
  • Backend does not stream large files
  • Better performance
  • Lower application server load
  • Temporary secure access
  • Useful for browser/mobile uploads

Architecture

flowchart TD
    U[User or Frontend]
    API[Spring Boot API]
    SDK[AWS SDK S3 Presigner]
    URL[Presigned URL]
    S3[(Private S3 Bucket)]

    U --> API
    API --> SDK
    SDK --> URL
    API --> U
    U --> S3

Download Flow

flowchart LR
    A[User Requests Download]
    B[Spring Boot Validates User]
    C[Generate GET Presigned URL]
    D[Return URL to User]
    E[User Downloads from S3]

    A --> B
    B --> C
    C --> D
    D --> E

Upload Flow

flowchart LR
    A[User Requests Upload URL]
    B[Spring Boot Validates File Info]
    C[Generate PUT Presigned URL]
    D[Return Upload URL]
    E[Frontend Uploads File to S3]

    A --> B
    B --> C
    C --> D
    D --> E

Prerequisites

You need:

AWS Account
S3 Bucket
Java 17 or Java 21
Spring Boot 3.x
Maven
AWS CLI configured
AWS SDK for Java 2.x

Verify AWS CLI:

aws sts get-caller-identity

Output:

{
  "UserId": "AIDAEXAMPLE",
  "Account": "123456789012",
  "Arn": "arn:aws:iam::123456789012:user/codewithvenu"
}

Step 1: Create S3 Bucket

Using AWS CLI:

aws s3api create-bucket \
  --bucket codewithvenu-presigned-url-demo \
  --region us-east-1

Output:

{
  "Location": "/codewithvenu-presigned-url-demo"
}

Keep the bucket private.

Do not disable Block Public Access.


Step 2: Create Spring Boot Project

Project structure:

springboot-s3-presigned-url-demo
 ┣ src/main/java/com/codewithvenu/presignedurl
 ┃ ┣ PresignedUrlApplication.java
 ┃ ┣ config
 ┃ ┃ ┗ AwsS3Config.java
 ┃ ┣ controller
 ┃ ┃ ┗ PresignedUrlController.java
 ┃ ┣ service
 ┃ ┃ ┗ PresignedUrlService.java
 ┃ ┗ dto
 ┃   ┣ PresignedDownloadRequest.java
 ┃   ┣ PresignedUploadRequest.java
 ┃   ┗ PresignedUrlResponse.java
 ┣ src/main/resources
 ┃ ┗ application.yml
 ┗ pom.xml

Step 3: Add Maven Dependencies

Update pom.xml:

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>s3</artifactId>
        <version>2.25.60</version>
    </dependency>

</dependencies>

Step 4: Configure application.yml

server:
  port: 8080

spring:
  application:
    name: springboot-s3-presigned-url-demo

aws:
  region: us-east-1
  s3:
    bucket-name: codewithvenu-presigned-url-demo
    presigned-url-expiration-minutes: 10

Do not store AWS keys in this file.


Step 5: Configure AWS S3 Presigner

Create:

src/main/java/com/codewithvenu/presignedurl/config/AwsS3Config.java
package com.codewithvenu.presignedurl.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
public class AwsS3Config {

    @Value("${aws.region}")
    private String awsRegion;

    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
                .region(Region.of(awsRegion))
                .credentialsProvider(DefaultCredentialsProvider.create())
                .build();
    }
}

Step 6: Create DTO Classes

PresignedDownloadRequest

package com.codewithvenu.presignedurl.dto;

import jakarta.validation.constraints.NotBlank;

public class PresignedDownloadRequest {

    @NotBlank(message = "Object key is required")
    private String objectKey;

    public String getObjectKey() {
        return objectKey;
    }

    public void setObjectKey(String objectKey) {
        this.objectKey = objectKey;
    }
}

PresignedUploadRequest

package com.codewithvenu.presignedurl.dto;

import jakarta.validation.constraints.NotBlank;

public class PresignedUploadRequest {

    @NotBlank(message = "File name is required")
    private String fileName;

    @NotBlank(message = "Content type is required")
    private String contentType;

    public String getFileName() {
        return fileName;
    }

    public String getContentType() {
        return contentType;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
}

PresignedUrlResponse

package com.codewithvenu.presignedurl.dto;

public class PresignedUrlResponse {

    private String objectKey;
    private String url;
    private String method;
    private int expiresInMinutes;

    public PresignedUrlResponse(String objectKey, String url, String method, int expiresInMinutes) {
        this.objectKey = objectKey;
        this.url = url;
        this.method = method;
        this.expiresInMinutes = expiresInMinutes;
    }

    public String getObjectKey() {
        return objectKey;
    }

    public String getUrl() {
        return url;
    }

    public String getMethod() {
        return method;
    }

    public int getExpiresInMinutes() {
        return expiresInMinutes;
    }
}

Step 7: Create Presigned URL Service

Create:

src/main/java/com/codewithvenu/presignedurl/service/PresignedUrlService.java
package com.codewithvenu.presignedurl.service;

import com.codewithvenu.presignedurl.dto.PresignedUrlResponse;
import com.codewithvenu.presignedurl.dto.PresignedUploadRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
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.PutObjectPresignRequest;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Service
public class PresignedUrlService {

    private final S3Presigner s3Presigner;

    @Value("${aws.s3.bucket-name}")
    private String bucketName;

    @Value("${aws.s3.presigned-url-expiration-minutes}")
    private int expirationMinutes;

    public PresignedUrlService(S3Presigner s3Presigner) {
        this.s3Presigner = s3Presigner;
    }

    public PresignedUrlResponse generateDownloadUrl(String objectKey) {

        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                .bucket(bucketName)
                .key(objectKey)
                .build();

        GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(expirationMinutes))
                .getObjectRequest(getObjectRequest)
                .build();

        String url = s3Presigner.presignGetObject(presignRequest)
                .url()
                .toString();

        return new PresignedUrlResponse(
                objectKey,
                url,
                "GET",
                expirationMinutes
        );
    }

    public PresignedUrlResponse generateUploadUrl(PresignedUploadRequest request) {

        validateContentType(request.getContentType());

        String objectKey = generateObjectKey(request.getFileName());

        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(objectKey)
                .contentType(request.getContentType())
                .build();

        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(expirationMinutes))
                .putObjectRequest(putObjectRequest)
                .build();

        String url = s3Presigner.presignPutObject(presignRequest)
                .url()
                .toString();

        return new PresignedUrlResponse(
                objectKey,
                url,
                "PUT",
                expirationMinutes
        );
    }

    private String generateObjectKey(String fileName) {
        String timestamp = LocalDateTime.now()
                .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));

        String safeFileName = fileName.replaceAll("[^a-zA-Z0-9\\.\\-_]", "_");

        return "uploads/" + timestamp + "-" + safeFileName;
    }

    private void validateContentType(String contentType) {
        if (!contentType.equals("image/png")
                && !contentType.equals("image/jpeg")
                && !contentType.equals("application/pdf")
                && !contentType.equals("text/plain")) {
            throw new IllegalArgumentException("Only PNG, JPEG, PDF, and TXT files are allowed");
        }
    }
}

Step 8: Create REST Controller

Create:

src/main/java/com/codewithvenu/presignedurl/controller/PresignedUrlController.java
package com.codewithvenu.presignedurl.controller;

import com.codewithvenu.presignedurl.dto.PresignedDownloadRequest;
import com.codewithvenu.presignedurl.dto.PresignedUploadRequest;
import com.codewithvenu.presignedurl.dto.PresignedUrlResponse;
import com.codewithvenu.presignedurl.service.PresignedUrlService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/s3/presigned")
public class PresignedUrlController {

    private final PresignedUrlService presignedUrlService;

    public PresignedUrlController(PresignedUrlService presignedUrlService) {
        this.presignedUrlService = presignedUrlService;
    }

    @PostMapping("/download")
    public ResponseEntity<PresignedUrlResponse> generateDownloadUrl(
            @Valid @RequestBody PresignedDownloadRequest request
    ) {
        return ResponseEntity.ok(
                presignedUrlService.generateDownloadUrl(request.getObjectKey())
        );
    }

    @PostMapping("/upload")
    public ResponseEntity<PresignedUrlResponse> generateUploadUrl(
            @Valid @RequestBody PresignedUploadRequest request
    ) {
        return ResponseEntity.ok(
                presignedUrlService.generateUploadUrl(request)
        );
    }
}

Step 9: Create Main Application Class

package com.codewithvenu.presignedurl;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PresignedUrlApplication {

    public static void main(String[] args) {
        SpringApplication.run(PresignedUrlApplication.class, args);
    }
}

Step 10: Run Application

Input:

mvn spring-boot:run

Output:

Tomcat started on port 8080
Started PresignedUrlApplication

Step 11: Generate PUT Presigned URL for Upload

Input:

curl -X POST http://localhost:8080/api/s3/presigned/upload \
  -H "Content-Type: application/json" \
  -d '{
    "fileName": "hello.txt",
    "contentType": "text/plain"
  }'

Output:

{
  "objectKey": "uploads/20260626103045-hello.txt",
  "url": "https://codewithvenu-presigned-url-demo.s3.amazonaws.com/uploads/20260626103045-hello.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256...",
  "method": "PUT",
  "expiresInMinutes": 10
}

Step 12: Upload File Using Presigned URL

Create file:

echo "Hello from CodeWithVenu Presigned URL Demo" > hello.txt

Use the URL from previous response:

curl -X PUT \
  -H "Content-Type: text/plain" \
  --upload-file hello.txt \
  "PASTE_PRESIGNED_PUT_URL_HERE"

Output:

No response body means upload is successful.

Validate using AWS CLI:

aws s3 ls s3://codewithvenu-presigned-url-demo/uploads/

Output:

2026-06-26 10:30:45         44 uploads/20260626103045-hello.txt

Step 13: Generate GET Presigned URL for Download

Input:

curl -X POST http://localhost:8080/api/s3/presigned/download \
  -H "Content-Type: application/json" \
  -d '{
    "objectKey": "uploads/20260626103045-hello.txt"
  }'

Output:

{
  "objectKey": "uploads/20260626103045-hello.txt",
  "url": "https://codewithvenu-presigned-url-demo.s3.amazonaws.com/uploads/20260626103045-hello.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256...",
  "method": "GET",
  "expiresInMinutes": 10
}

Step 14: Download File Using Presigned URL

Input:

curl -o downloaded-hello.txt "PASTE_PRESIGNED_GET_URL_HERE"

Output:

File downloaded as downloaded-hello.txt

Verify:

cat downloaded-hello.txt

Output:

Hello from CodeWithVenu Presigned URL Demo

Step 15: Frontend Upload Flow

Frontend should follow this flow:

flowchart TD
    A[User Selects File]
    B[Frontend Calls Spring Boot for PUT URL]
    C[Spring Boot Returns Presigned URL]
    D[Frontend Uploads File Directly to S3]
    E[Frontend Sends Object Key to Backend]
    F[Backend Saves Metadata in Database]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F

Example JavaScript:

async function uploadFile(file) {
  const response = await fetch("/api/s3/presigned/upload", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      fileName: file.name,
      contentType: file.type
    })
  });

  const data = await response.json();

  await fetch(data.url, {
    method: "PUT",
    headers: {
      "Content-Type": file.type
    },
    body: file
  });

  return data.objectKey;
}

Step 16: IAM Policy

Minimum IAM permissions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "S3PresignedListAccess",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::codewithvenu-presigned-url-demo"
      ]
    },
    {
      "Sid": "S3PresignedObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::codewithvenu-presigned-url-demo/uploads/*"
      ]
    }
  ]
}

For production, attach this to:

EC2 IAM Role
ECS Task Role
EKS Service Account Role
Lambda Execution Role

Do not hardcode AWS access keys.


Step 17: CORS Configuration for Browser Uploads

If frontend uploads directly to S3 from browser, configure S3 CORS.

Example CORS:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedOrigins": ["https://codewithvenu.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

For local development:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedOrigins": ["http://localhost:3000"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Avoid using "*" for production origins.


Common Errors and Fixes

Error 1: SignatureDoesNotMatch

Reason:

Content-Type used while uploading does not match the Content-Type used while generating the presigned URL.

Fix:

Use same content type in both places:

-H "Content-Type: text/plain"

Error 2: AccessDenied

Reason:

IAM role does not have s3:GetObject or s3:PutObject permission.

Fix:

Update IAM policy.


Error 3: Expired URL

Reason:

Presigned URL expired.

Fix:

Generate a new URL.


Error 4: CORS Error in Browser

Reason:

S3 bucket CORS is not configured for frontend domain.

Fix:

Add CORS rule for your frontend URL.


Error 5: Bucket Public Access Confusion

Presigned URLs do not require public bucket access.

Keep Block Public Access enabled.


Best Practices

  • Keep S3 bucket private
  • Use short expiration time
  • Validate user permissions before generating URL
  • Validate content type
  • Validate file size before upload
  • Use unique object keys
  • Store object metadata in database
  • Use versioned image/file names
  • Use presigned PUT URL for direct browser upload
  • Use presigned GET URL for temporary download
  • Configure CORS carefully
  • Use IAM roles in production
  • Avoid hardcoding AWS credentials
  • Use CloudTrail for audit
  • Use S3 lifecycle policies
  • Use KMS encryption for sensitive files

Presigned URL Security Checklist

flowchart TD
    A[User Requests URL]
    B[Authenticate User]
    C[Authorize File Access]
    D[Validate File Type and Size]
    E[Generate Short-Lived URL]
    F[Return URL]
    G[Audit Access]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G

Interview Questions

What is an S3 presigned URL?

An S3 presigned URL is a temporary URL that allows limited-time access to a private S3 object.

Why use presigned URLs?

They allow secure upload or download without making the bucket public and without routing large files through the backend server.

Can a presigned URL access a private bucket?

Yes. The bucket can remain private. The presigned URL grants temporary access using the permissions of the IAM principal that generated it.

What is the difference between GET and PUT presigned URLs?

GET presigned URL is used to download an object. PUT presigned URL is used to upload an object.

Should presigned URLs be stored in database?

Usually no. Store the object key in the database. Generate presigned URLs only when needed.


Summary

In this article, we learned how to generate S3 presigned URLs using Spring Boot and AWS SDK for Java.

We covered:

  • GET presigned URL
  • PUT presigned URL
  • Secure direct upload
  • Secure direct download
  • AWS SDK configuration
  • IAM permissions
  • S3 CORS setup
  • Input and output examples
  • Common errors
  • Best practices

Presigned URLs are one of the best ways to handle secure file upload and download in modern Spring Boot applications.


Loading likes...

Comments

Share a question, correction, or practical insight about this article.

Loading approved comments...