S3 in Spring Boot: Secure File Storage and Proper Permission Management

1. Overview

Amazon S3 is a popular choice for storing files like invoices, profile pictures, and attachments. Uploading typically takes just a few lines of code. The harder part is permissions: who can read, who can write, and how to share files without making the bucket public.

In this article, you’ll use AWS SDK for Java v2 with Spring Boot 3.x to store and retrieve files. Then, you’ll configure permissions using IAM policies and bucket policies, and use presigned URLs to share files with expiration. At the end, there’s a test that runs with LocalStack.

2. Setting Up Your Project

2.1. Dependency

You should use the AWS SDK BOM to keep modules in sync:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>bom</artifactId>
            <version>2.31.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>s3</artifactId>
    </dependency>
</dependencies>

The s3 module already includes both S3Client and S3Presigner, so you don’t need any other modules.

2.2. Bean Configuration

Declare the region and bucket name in application.properties:

app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod

Next, create two beans for S3Client and S3Presigner:

@Configuration
public class S3Config {

    @Bean
    S3Client s3Client(@Value("${app.s3.region}") String region) {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean
    S3Presigner s3Presigner(@Value("${app.s3.region}") String region) {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

The code above doesn’t declare credentials. The SDK uses DefaultCredentialsProvider and looks for credentials in environment variables, the ~/.aws/credentials file, and then the IAM role of EC2/ECS/EKS. On production, you should run the application with an IAM role.

Note: Don’t hard-code access keys in your code or application.properties. Leaking keys through Git is one of the most common AWS incidents.

3. Storing and Reading Files

The example in this article is a service that stores PDF invoices for customers. Each file is stored under a prefix based on customerId. This key naming approach makes it easier to verify ownership, as you’ll see in section 5.

@Service
public class InvoiceStorageService {

    private final S3Client s3;
    private final S3Presigner presigner;
    private final String bucket;

    public InvoiceStorageService(S3Client s3, S3Presigner presigner,
                                 @Value("${app.s3.bucket}") String bucket) {
        this.s3 = s3;
        this.presigner = presigner;
        this.bucket = bucket;
    }

    public String upload(long customerId, InputStream content, long size) {
        String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("application/pdf")
                .serverSideEncryption(ServerSideEncryption.AES256)
                .build();

        s3.putObject(request, RequestBody.fromInputStream(content, size));
        return key;
    }

    public byte[] download(String key) {
        return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asByteArray();
    }

    public void delete(String key) {
        s3.deleteObject(b -> b.bucket(bucket).key(key));
    }
}

Key points in the code above:

  • The key has the format invoices/42/7f3c...pdf. The UUID part prevents name collisions. We also don’t include the user’s filename in the key, avoiding strange characters and path traversal issues.
  • contentType is stored with the object. When the browser downloads the file via a presigned URL, it will display the PDF correctly.
  • serverSideEncryption(AES256) requests S3 to encrypt the file at rest (SSE-S3). S3 has this enabled by default now. Specifying it in the request is still useful if your bucket policy enforces encryption headers.
  • RequestBody.fromInputStream needs to know the size in advance. If you receive a MultipartFile, pass file.getInputStream() and file.getSize().
  • getObjectAsBytes reads the entire file into memory. This approach is only suitable for small files.

Note: For large files (hundreds of MB), don’t use getObjectAsBytes. Use s3.getObject(...) to get a ResponseInputStream and stream it directly to the response. Alternatively, let clients download directly via presigned URL (section 5).

4. S3 Permission Model

An S3 request is accepted only if it passes multiple validation layers:

  1. Block Public Access: settings at the account and bucket level. When enabled, any policy or ACL granting public access is disabled.
  2. IAM policy: attached to users or roles, describing what that identity can do.
  3. Bucket policy: attached to the bucket, describing who can do what to it.
  4. ACL: a legacy mechanism. New buckets default to Object Ownership BucketOwnerEnforced, meaning ACLs are disabled.

The evaluation rule is simple: an explicit Deny anywhere always wins. If there’s no Deny, the request needs at least one matching Allow.

Recommendations for most applications:

  • Keep Block Public Access enabled for all four settings.
  • Keep ACL disabled (BucketOwnerEnforced).
  • Grant permissions to the application via an IAM role.
  • Use a bucket policy for common restrictions, like enforcing TLS.

4.1. IAM policy with least privilege

The application role only needs to read, write, and delete objects under the invoices/ prefix:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvoiceObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::nonla-invoices-prod/invoices/*"
    },
    {
      "Sid": "ListInvoicePrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::nonla-invoices-prod",
      "Condition": {
        "StringLike": { "s3:prefix": "invoices/*" }
      }
    }
  ]
}

Notice the two different ARN formats:

  • Object operations use ARNs ending with /*.
  • s3:ListBucket is a bucket-level operation, so it uses an ARN without /*.

Mixing up these ARN formats is the most common cause of AccessDenied errors. Another detail: if s3:ListBucket is missing, S3 returns 403 instead of 404 when an object doesn’t exist. This is S3’s intentional behavior to avoid revealing whether a key exists.

4.2. Bucket policy enforcing TLS

Bucket policy is ideal for setting rules that apply to all callers, including admins:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::nonla-invoices-prod",
        "arn:aws:s3:::nonla-invoices-prod/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

This statement is a Deny, so all plain HTTP requests are blocked. No other Allow can override it. SDK v2 uses HTTPS by default, so your application is unaffected.

5. Sharing Files with Presigned URLs

Customers need to download invoices, but you don’t want to make the bucket public or stream every file through your server. Presigned URLs solve this perfectly. It’s a URL signed with your application’s credentials that’s only valid for a short time.

Add these two methods to InvoiceStorageService:

public URL presignDownload(long customerId, String key, Duration ttl) {
    if (!key.startsWith("invoices/%d/".formatted(customerId))) {
        throw new ForbiddenFileAccessException(customerId, key);
    }

    GetObjectPresignRequest request = GetObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .getObjectRequest(b -> b.bucket(bucket).key(key))
            .build();

    return presigner.presignGetObject(request).url();
}

public URL presignUpload(long customerId, Duration ttl) {
    String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

    PutObjectPresignRequest request = PutObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .putObjectRequest(b -> b.bucket(bucket).key(key).contentType("application/pdf"))
            .build();

    return presigner.presignPutObject(request).url();
}

A simple exception class:

public class ForbiddenFileAccessException extends RuntimeException {
    public ForbiddenFileAccessException(long customerId, String key) {
        super("Customer %d cannot access %s".formatted(customerId, key));
    }
}

How the code works:

  • The startsWith check is application-level authorization. S3 only knows your application can read all of invoices/*. It doesn’t know that customer 42 shouldn’t see customer 43’s invoice. Without this check, anyone who guesses a key can download another customer’s file.
  • signatureDuration sets the URL’s expiration time. For downloads, a few minutes is usually sufficient.
  • For presigned PUT, the client must send the correct Content-Type: application/pdf header. Otherwise, the signature won’t match.

The generated URL looks like:

https://nonla-invoices-prod.s3.ap-southeast-1.amazonaws.com/invoices/42/7f3c...pdf
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Date=20261013T080000Z
  &X-Amz-Expires=300
  &X-Amz-Credential=...
  &X-Amz-Signature=...

Clients can upload directly to S3 using presigned PUT:

curl -X PUT -H "Content-Type: application/pdf" \
     --upload-file invoice.pdf "<presigned-url>"

Note: Presigned URLs carry the permissions of who signed them. If the application’s IAM role loses s3:GetObject permission, the issued URL also becomes invalid. The maximum validity period is 7 days. When signed with temporary credentials from an IAM role, the URL expires earlier, along with the role’s session (usually a few hours). Don’t store presigned URLs in a database for long-term use. Store the key instead and generate a new URL each time.

6. Testing with LocalStack

Add Testcontainers to run a mock S3 inside Docker:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.x manages the Testcontainers version, so you don’t need to specify it.

@Testcontainers
class InvoiceStorageServiceTest {

    @Container
    static LocalStackContainer localstack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
                    .withServices(LocalStackContainer.Service.S3);

    static InvoiceStorageService service;

    @BeforeAll
    static void setUp() {
        var credentials = StaticCredentialsProvider.create(
                AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()));
        var region = Region.of(localstack.getRegion());

        S3Client s3 = S3Client.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .forcePathStyle(true)
                .build();

        S3Presigner presigner = S3Presigner.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .build();

        s3.createBucket(b -> b.bucket("test-invoices"));
        service = new InvoiceStorageService(s3, presigner, "test-invoices");
    }

    @Test
    void givenUploadedInvoice_whenDownload_thenContentIsSame() {
        byte[] pdf = "%PDF-1.7 fake invoice".getBytes(StandardCharsets.UTF_8);

        String key = service.upload(42L, new ByteArrayInputStream(pdf), pdf.length);

        assertThat(key).startsWith("invoices/42/").endsWith(".pdf");
        assertThat(service.download(key)).isEqualTo(pdf);
    }

    @Test
    void givenOwnKey_whenPresign_thenUrlHasExpiry() {
        URL url = service.presignDownload(42L, "invoices/42/a.pdf", Duration.ofMinutes(5));

        assertThat(url.getQuery()).contains("X-Amz-Expires=300");
    }

    @Test
    void givenOtherCustomersKey_whenPresign_thenForbidden() {
        assertThatThrownBy(() ->
                service.presignDownload(42L, "invoices/43/b.pdf", Duration.ofMinutes(5)))
                .isInstanceOf(ForbiddenFileAccessException.class);
    }
}

A few notable details:

  • forcePathStyle(true) allows bucket access in the format http://localhost:4566/test-invoices/.... LocalStack requires this format because it can’t resolve subdomains like test-invoices.localhost.
  • The third test is the most important from a security perspective. It ensures customer 42 can’t obtain a URL for customer 43’s file.

LocalStack doesn’t evaluate IAM policies in its free version. So you should verify your policies using the IAM Policy Simulator or a staging environment.

7. Common Errors

  • AccessDenied despite having s3:GetObject: Missing /* in the ARN, wrong bucket name, or a Deny bucket policy is blocking it. If the bucket uses SSE-KMS, the role also needs kms:Decrypt permission on the corresponding KMS key.
  • 403 instead of 404 when an object doesn’t exist: the role is missing s3:ListBucket. This is S3’s correct behavior, not a bug.
  • SignatureDoesNotMatch with presigned PUT: the client sends a different Content-Type than when signing, or the presigner is configured for a different region.
  • Making content public via ACL public-read: with BucketOwnerEnforced, requests with ACLs are rejected. If you truly need public content, like product images, put CloudFront in front and use Origin Access Control. Don’t disable Block Public Access.
  • Creating a new S3Client for each request: clients maintain a connection pool and should be reused. Declare it as a singleton bean as shown in section 2.2.

Note: S3 is not a file system. You don’t have “rename” or “append” operations. Renaming means copying to a new key then deleting the old one. If you need to continuously append to a file, S3 isn’t the right choice.

8. Conclusion

In this article, you’ve learned to upload, download, and delete files on S3 using AWS SDK v2 in Spring Boot, with keys designed around customerId. You’ve also configured permissions at multiple levels:

  • Block Public Access and ACL disabled to keep the bucket private.
  • IAM policy with least privilege for the application role.
  • Bucket policy for common rules like enforcing TLS.
  • Application-level ownership checks before issuing presigned URLs.

The key takeaway: S3 only knows what your application is allowed to do. Determining which user can view which file remains your application’s responsibility. That’s why you need tests covering the case of users accessing other users’ files.

awss3spring-bootiamjava