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. contentTypeis 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.fromInputStreamneeds to know thesizein advance. If you receive aMultipartFile, passfile.getInputStream()andfile.getSize().getObjectAsBytesreads the entire file into memory. This approach is only suitable for small files.
Note: For large files (hundreds of MB), don’t use
getObjectAsBytes. Uses3.getObject(...)to get aResponseInputStreamand 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:
- Block Public Access: settings at the account and bucket level. When enabled, any policy or ACL granting public access is disabled.
- IAM policy: attached to users or roles, describing what that identity can do.
- Bucket policy: attached to the bucket, describing who can do what to it.
- 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:ListBucketis 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
startsWithcheck is application-level authorization. S3 only knows your application can read all ofinvoices/*. 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. signatureDurationsets 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/pdfheader. 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:GetObjectpermission, 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 formathttp://localhost:4566/test-invoices/.... LocalStack requires this format because it can’t resolve subdomains liketest-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
AccessDenieddespite havings3:GetObject: Missing/*in the ARN, wrong bucket name, or aDenybucket policy is blocking it. If the bucket uses SSE-KMS, the role also needskms:Decryptpermission on the corresponding KMS key.403instead of404when an object doesn’t exist: the role is missings3:ListBucket. This is S3’s correct behavior, not a bug.SignatureDoesNotMatchwith presigned PUT: the client sends a differentContent-Typethan when signing, or the presigner is configured for a different region.- Making content public via ACL
public-read: withBucketOwnerEnforced, 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
S3Clientfor 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.