S3 and R2 Presigned URLs with Spring Boot: Secure File Uploads

Every app that handles user uploads hits the same wall: how do you let people upload directly to cloud storage without exposing your credentials? Presigned URLs solve this, and Spring Boot makes it straightforward.

S3 and R2 Presigned URLs with Spring Boot: Secure File Uploads

I once got a Cloudflare R2 bill that was almost entirely egress charges. We were serving profile pictures through our backend, every request proxying image bytes through our servers. The storage cost was negligible. The bandwidth cost wasn’t. That’s when I learned about presigned URLs, and I’ve used them for every file upload since.

The idea is simple: instead of routing uploads through your server, your server generates a temporary, signed URL. The frontend uploads directly to S3 or R2. Your server never touches the file bytes. It’s one of those things that seems small but changes how you architect your entire upload flow.

What Is a Presigned URL?

A presigned URL is a temporary, secure URL that grants limited access to a specific object in your S3 or R2 bucket. It’s like a time-limited guest pass: you control what the holder can do, which file they can touch, and for how long.

Without Presigned URL:
Frontend POST /upload Spring Boot S3 Bucket
     Server handles upload (bottleneck, expensive)

With Presigned URL:
Frontend PUT presigned URL S3 Bucket (direct upload)
Spring Boot (just returns a signed URL)

Your Spring Boot server never sees the file data. It issues a signed URL, and the frontend uploads straight to cloud storage. Done.

Why Proxying Uploads Through Your Server Is a Mistake

I’ve seen this pattern more times than I’d like to admit. It looks innocent:

// [ ] The naive approach - don't do this
@PostMapping("/upload")
public ResponseEntity<?> upload(@RequestParam MultipartFile file) {
    s3Client.putObject(PutObjectRequest.builder()
        .bucket("my-bucket")
        .key(file.getOriginalFilename())
        .build(),
        RequestBody.fromBytes(file.getBytes())
    );
    // Your server just consumed CPU, memory, and bandwidth
    // for data that was always headed to S3 anyway
}

It works. And then it doesn’t.

Why Server-Side Uploads Fall Apart

ProblemImpact
Bandwidth costsEvery byte passes through your server twice (in + out)
Server loadLarge files tie up request threads and memory
Scaling complexityYou need bigger instances just to proxy file data
Timeout risksLong uploads can hit reverse proxy / load balancer limits
Credential exposureYour server holds permanent S3 keys

What You Get with Presigned URLs

BenefitHow
SecurityNo permanent credentials ever reach the client
PerformanceDirect upload bypasses your server entirely
Cost savingsData transfer goes straight to S3/R2, not through your infra
ScalabilityYour server handles only lightweight URL generation
ControlYou set expiration time, file size limits, and allowed operations

How Presigned URLs Actually Work

The whole thing hinges on a cryptographic signature. Your server uses its secret access key to sign the request parameters. S3 or R2 can verify that signature without ever seeing your secret key.

1. Frontend: "I need to upload profile-photo.jpg"
2. Spring Boot: GET /api/upload-url generates presigned URL (valid 10 min, PUT only)
3. Spring Boot: Returns the signed URL to frontend
4. Frontend: PUT request directly to presigned URL with file bytes
5. S3/R2: Verifies signature, accepts upload
6. Done. File is in your bucket.

The URL itself encodes the permissions:

https://my-bucket.s3.amazonaws.com/uploads/profile-photo.jpg
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=AKIA.../20260610/us-east-1/s3/aws4_request
  &X-Amz-Date=20260610T120000Z
  &X-Amz-Expires=600
  &X-Amz-Signature=9f3a...b2d1

S3 vs R2: What’s the Difference?

Cloudflare R2 was built as an S3-compatible alternative with one killer feature: no egress fees.

FeatureAWS S3Cloudflare R2
API CompatibilityNative S3 APIS3-compatible API
Egress Cost$0.09/GB$0 (free)
Storage Cost~$0.023/GB$0.015/GB
Presigned URL SupportNativeFull support via S3 SDK
Global NetworkRegional bucketsDistributed by default
Operations CostClass A/B chargesNo charge for Class A, $0.36/million for Class B

The thing I like most: R2 uses the same S3 API. The Java code you write for S3 presigned URLs works on R2 with just an endpoint change. No rewrites.

Setting Up the AWS SDK for Java

Add the AWS SDK dependency to your Spring Boot project. You only need the S3 module - nothing else.

Maven (pom.xml):

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

Gradle (build.gradle):

dependencies {
    implementation 'software.amazon.awssdk:s3:2.30.38'
}

Spring Boot: S3 Presigned Upload URL API

First, configure the S3 presigner as a Spring bean. This gets injected wherever you need it.

Configuration

// application.yml
aws:
  s3:
    region: us-east-1
    bucket: my-app-uploads
    access-key-id: ${AWS_ACCESS_KEY_ID}
    secret-access-key: ${AWS_SECRET_ACCESS_KEY}
// S3Config.java
package com.example.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.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
public class S3Config {

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

    @Value("${aws.s3.access-key-id}")
    private String accessKeyId;

    @Value("${aws.s3.secret-access-key}")
    private String secretAccessKey;

    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
            .region(Region.of(region))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKeyId, secretAccessKey)
            ))
            .build();
    }
}

The Presigned URL Service

// PresignedUrlService.java
package com.example.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

import java.time.Duration;
import java.util.UUID;

@Service
public class PresignedUrlService {

    private final S3Presigner presigner;
    private final String bucket;

    public PresignedUrlService(
        S3Presigner presigner,
        @Value("${aws.s3.bucket}") String bucket
    ) {
        this.presigner = presigner;
        this.bucket = bucket;
    }

    public PresignedUrlResponse generateUploadUrl(
        String fileName,
        String contentType,
        String folder
    ) {
        String key = "%s/%s-%s".formatted(
            folder,
            System.currentTimeMillis(),
            sanitizeFileName(fileName)
        );

        PutObjectRequest objectRequest = PutObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .contentType(contentType)
            .build();

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

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

        return new PresignedUrlResponse(url, key);
    }

    private String sanitizeFileName(String fileName) {
        return fileName.replaceAll("[^a-zA-Z0-9._-]", "_");
    }
}

DTO

// PresignedUrlResponse.java
package com.example.dto;

public record PresignedUrlResponse(
    String uploadUrl,
    String key
) {}

// UploadUrlRequest.java
package com.example.dto;

public record UploadUrlRequest(
    String fileName,
    String contentType,
    String folder
) {}

The REST Controller

// UploadController.java
package com.example.controller;

import com.example.dto.UploadUrlRequest;
import com.example.dto.PresignedUrlResponse;
import com.example.service.PresignedUrlService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class UploadController {

    private final PresignedUrlService presignedUrlService;

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

    @PostMapping("/upload-url")
    public ResponseEntity<PresignedUrlResponse> getUploadUrl(
        @RequestBody UploadUrlRequest request
    ) {
        PresignedUrlResponse response = presignedUrlService.generateUploadUrl(
            request.fileName(),
            request.contentType(),
            request.folder()
        );
        return ResponseEntity.ok(response);
    }
}

That’s it. Your frontend now hits POST /api/upload-url with a JSON body, gets back a presigned URL, and uploads directly to S3.

Spring Boot: R2 Presigned Upload URL API

For Cloudflare R2, you only need to change the presigner configuration. The service and controller stay identical.

R2 Configuration

// R2Config.java
package com.example.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.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

import java.net.URI;

@Configuration
public class R2Config {

    @Value("${cloudflare.r2.account-id}")
    private String accountId;

    @Value("${cloudflare.r2.access-key-id}")
    private String accessKeyId;

    @Value("${cloudflare.r2.secret-access-key}")
    private String secretAccessKey;

    @Bean
    public S3Presigner r2Presigner() {
        return S3Presigner.builder()
            .region(Region.of("auto"))
            .endpointOverride(URI.create(
                "https://%s.r2.cloudflarestorage.com".formatted(accountId)
            ))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKeyId, secretAccessKey)
            ))
            .build();
    }
}
# application.yml
cloudflare:
  r2:
    account-id: ${R2_ACCOUNT_ID}
    bucket: my-r2-bucket
    access-key-id: ${R2_ACCESS_KEY_ID}
    secret-access-key: ${R2_SECRET_ACCESS_KEY}

The PresignedUrlService is identical, just inject the R2 presigner bean and point the bucket name at your R2 bucket. No other code changes needed.

What the Frontend Does

Once Spring Boot hands the frontend a presigned URL, uploading is a straightforward HTTP PUT:

async function uploadFile(file) {
  // Step 1: Get presigned URL from your Spring Boot API
  const response = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      fileName: file.name,
      contentType: file.type,
      folder: "avatars",
    }),
  });
  const { uploadUrl, key } = await response.json();

  // Step 2: Upload directly to S3/R2 (not through your server)
  const uploadResponse = await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  });

  if (!uploadResponse.ok) {
    throw new Error("Upload failed");
  }

  // Step 3: Optionally save `key` to your backend for future reference
  return { key, publicUrl: `https://cdn.yourdomain.com/${key}` };
}

Presigned URLs for Downloads Too

The pattern works both ways. Expose an endpoint that generates a download URL for private files:

// Inside PresignedUrlService.java
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

public String generateDownloadUrl(String key, Duration expiration) {
    GetObjectRequest objectRequest = GetObjectRequest.builder()
        .bucket(bucket)
        .key(key)
        .build();

    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
        .getObjectRequest(objectRequest)
        .signatureDuration(expiration)
        .build();

    return presigner.presignGetObject(presignRequest).url().toString();
}
// DownloadController.java
@GetMapping("/api/download-url/{fileId}")
public ResponseEntity<Map<String, String>> getDownloadUrl(@PathVariable String fileId) {
    // Fetch the actual S3 key from your database
    String key = fileRepository.getKeyByFileId(fileId);

    // Optional: verify the user has access to this file
    if (!hasAccess(fileId)) {
        return ResponseEntity.status(403).build();
    }

    String url = presignedUrlService.generateDownloadUrl(key, Duration.ofMinutes(5));
    return ResponseEntity.ok(Map.of("downloadUrl", url));
}

Security Best Practices

Set the Shortest Practical Expiration

// Upload URLs: shorter is better - the client uses them immediately
private static final Duration UPLOAD_EXPIRY = Duration.ofMinutes(10);

// Download URLs: depends on use case
private static final Duration DOWNLOAD_EXPIRY = Duration.ofMinutes(5);
private static final Duration SHARE_LINK_EXPIRY = Duration.ofHours(24);

Always Specify ContentType

Without it, a malicious file uploaded as image/png could be stored as binary/octet-stream and later executed by a browser. Bind the content type to the presigned URL so S3 enforces it:

// [x] Good: ContentType is explicit
PutObjectRequest.builder()
    .bucket(bucket)
    .key(key)
    .contentType("image/png")

// [ ] Bad: Missing ContentType - undefined behavior
PutObjectRequest.builder()
    .bucket(bucket)
    .key(key)

Validate Inputs on the Controller

The presigned URL locks in the content type, but the frontend still controls what it sends you. Validate before generating:

@PostMapping("/upload-url")
public ResponseEntity<?> getUploadUrl(@RequestBody UploadUrlRequest request) {
    // Whitelist allowed content types
    Set<String> allowedTypes = Set.of(
        "image/jpeg", "image/png", "image/webp", "application/pdf"
    );

    if (!allowedTypes.contains(request.contentType())) {
        return ResponseEntity.badRequest()
            .body(Map.of("error", "Unsupported file type"));
    }

    // Rate limit per user
    if (rateLimiter.isExceeded(getCurrentUserId())) {
        return ResponseEntity.status(429)
            .body(Map.of("error", "Too many requests"));
    }

    PresignedUrlResponse response = presignedUrlService.generateUploadUrl(
        request.fileName(), request.contentType(), request.folder()
    );
    return ResponseEntity.ok(response);
}

Scope Keys by User or Tenant

Prevent users from guessing or overwriting each other’s files:

// [x] User-scoped key
String key = "users/%s/%s/%s-%s".formatted(
    userId, folder, System.currentTimeMillis(), fileName
);

// [ ] Global flat namespace - anyone can overwrite
String key = "%s/%s".formatted(folder, fileName);

Use POST Policies for Stricter Controls

For fine-grained control (file size limits enforced by S3 itself), use S3Presigner’s post policy support:

import software.amazon.awssdk.services.s3.presigner.model.PresignedPostRequest;

public PresignedPost generateUploadPost(String key, String contentType) {
    return presigner.presignPost(PresignedPostRequest.builder()
        .putObjectRequest(PutObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .contentType(contentType)
            .build())
        .signatureDuration(Duration.ofMinutes(10))
        .build()
    );
}

This returns a URL and form fields - the frontend posts a multipart form instead of a raw PUT.

Common Pitfalls and How to Avoid Them

CORS Errors

S3 and R2 buckets need explicit CORS configuration for browser uploads. Configure this in the S3 console under Bucket Permissions CORS configuration, or via the R2 dashboard under Bucket Settings CORS:

[
  {
    "AllowedOrigins": ["https://yourdomain.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD", "POST"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

I’ve wasted entire afternoons chasing upload failures that turned out to be missing CORS headers. Don’t skip this step.

Expired URLs on Slow Connections

S3 validates the signature only at request start, once the PUT begins, it completes regardless of expiration. But if the connection drops and the frontend retries, it needs a fresh URL. Handle this gracefully:

// Frontend: retry with a fresh URL on failure
async function uploadWithRetry(file, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const { uploadUrl } = await fetch("/api/upload-url", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          fileName: file.name,
          contentType: file.type,
          folder: "uploads",
        }),
      }).then((r) => r.json());

      await fetch(uploadUrl, { method: "PUT", body: file });
      return;
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
    }
  }
}

Large Files Without Multipart Upload

For files over 100MB, presigned URLs still work - generate one per part. Spring Boot handles this cleanly:

import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;

public MultipartUploadResponse initiateMultipartUpload(
    String key, String contentType, int totalParts
) {
    CreateMultipartUploadRequest createRequest = CreateMultipartUploadRequest.builder()
        .bucket(bucket)
        .key(key)
        .contentType(contentType)
        .build();

    String uploadId = s3Client.createMultipartUpload(createRequest).uploadId();

    List<String> partUrls = new ArrayList<>();
    for (int i = 1; i <= totalParts; i++) {
        UploadPartRequest partRequest = UploadPartRequest.builder()
            .bucket(bucket)
            .key(key)
            .uploadId(uploadId)
            .partNumber(i)
            .build();

        String partUrl = presigner.presignUploadPart(PutObjectPresignRequest.builder()
            .putObjectRequest(PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build())
            .signatureDuration(Duration.ofHours(1))
            .build()
        ).url().toString();

        partUrls.add(partUrl);
    }

    return new MultipartUploadResponse(uploadId, partUrls);
}

Which Should You Choose: S3 or R2?

Choose S3 When

  • You’re deep in the AWS ecosystem (Lambda, CloudFront, IAM)
  • You need advanced features like S3 Object Lambda, S3 Select, or replication rules
  • Regional data residency requirements matter and AWS has a region near you

Choose R2 When

  • Egress costs are a concern (serving files to users frequently)
  • You want simpler pricing without operation charges
  • You’re already using Cloudflare for DNS/CDN
  • You want globally distributed storage without cross-region replication complexity
  • You’re building bandwidth-heavy applications (video streaming, large file sharing)

Many teams use both: S3 as the primary store with R2 as a CDN-adjacent cache layer to eliminate egress costs on frequently accessed files.

What I’d Do Differently

If I were setting up file uploads from scratch today, I’d wire up presigned URLs from day one. The setup takes maybe an hour, and it saves you from the mess I went through, proxying uploads, debugging timeout issues, and staring at a bandwidth bill that made no sense.

The pattern is simple: your server generates time-limited signed URLs, the frontend uploads directly to cloud storage, and your infrastructure never touches the file bytes. Once you’ve built it, you won’t go back to the old way.

One thing to remember: configure CORS on your bucket before you start testing from a browser. I’ve lost more time to that than I care to admit.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.