S3 en Spring Boot: guardar archivos con seguridad y permisos
1. Descripción general
Amazon S3 es la opción más popular para guardar archivos como facturas, avatares o adjuntos. La parte de upload suele ser solo unas pocas líneas de código. La parte difícil es la gestión de permisos: quién puede leer, quién puede escribir, y cómo compartir archivos sin abrir el bucket al público.
En este artículo, usarás AWS SDK for Java v2 en Spring Boot 3.x para guardar y leer archivos. Después, configurarás permisos con IAM policy y bucket policy, y usarás presigned URL para compartir archivos con tiempo de expiración. Al final del artículo hay un test ejecutado con LocalStack.
2. Configuración del proyecto
2.1. Dependencias
Deberías usar el BOM de AWS SDK para asegurar que todos los módulos siempre tengan la misma versión:
<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>
El módulo s3 ya incluye tanto S3Client como S3Presigner, así que no necesitas agregar ningún otro módulo.
2.2. Configuración del bean
Declara la región y el nombre del bucket en application.properties:
app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod
Luego, crea dos beans S3Client y 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();
}
}
El código anterior no declara credenciales. El SDK usará DefaultCredentialsProvider y buscará credenciales sucesivamente en variables de entorno, archivo ~/.aws/credentials, y luego en el rol IAM de EC2/ECS/EKS. En un entorno de producción, deberías dejar que la aplicación se ejecute con un rol IAM.
Nota: No codifiques access keys en el código ni en
application.properties. Las keys filtradas a través de Git son uno de los incidentes de AWS más comunes.
3. Guardar y leer archivos
El ejemplo en este artículo es un servicio que guarda facturas PDF para clientes. Cada archivo está bajo un prefijo según el customerId. Esta forma de establecer la key te ayuda a verificar la propiedad más fácilmente en la sección 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));
}
}
Los puntos clave en el código:
- La key tiene la forma
invoices/42/7f3c...pdf. La parte UUID evita conflictos de nombres. Tampoco incluyes el nombre del archivo del usuario en la key, evitando así caracteres extraños y path traversal. contentTypese guarda junto con el objeto. Cuando el navegador descarga el archivo a través de una presigned URL, mostrará el PDF correctamente.serverSideEncryption(AES256)solicita a S3 que cifre el archivo al guardarlo (SSE-S3). Actualmente S3 lo habilita por defecto. Especificarlo explícitamente en la request es útil si la bucket policy requiere este encabezado de cifrado.RequestBody.fromInputStreamnecesita conocer elsizede antemano. Si recibes unMultipartFile, pasafile.getInputStream()yfile.getSize().getObjectAsBytescarga el archivo completo en memoria. Este enfoque solo es apropiado para archivos pequeños.
Nota: Con archivos grandes (cientos de MB), no uses
getObjectAsBytes. Usas3.getObject(...)para obtenerResponseInputStreamy transmite directamente en la response. Otra opción es dejar que el cliente descargue directamente a través de una presigned URL (sección 5).
4. Modelo de permisos de S3
Una request a S3 solo se acepta si pasa múltiples capas de verificación:
- Block Public Access: un interruptor a nivel de cuenta y bucket. Cuando está habilitado, todas las policies o ACLs que concedan permisos públicos se invalidan.
- IAM policy: se adjunta a un usuario o rol, describe qué puede hacer esa identidad.
- Bucket policy: se adjunta al bucket, describe quién puede hacer qué en ese bucket.
- ACL: mecanismo antiguo. Los buckets nuevos usan por defecto
BucketOwnerEnforcedpara Object Ownership, es decir, ACL está deshabilitado.
La regla de evaluación es muy simple: un Deny explícito en cualquier lugar siempre gana. Si no hay Deny, la request necesita al menos un Allow que coincida.
Recomendación para la mayoría de aplicaciones:
- Mantén Block Public Access habilitado en las cuatro opciones.
- Mantén ACL deshabilitado (
BucketOwnerEnforced). - Concede permisos a la aplicación a través de rol IAM.
- Usa bucket policy para restricciones generales, por ejemplo, obligar TLS.
4.1. IAM policy según el principio de mínimo privilegio
El rol de la aplicación solo necesita leer, escribir y eliminar objetos bajo el prefijo invoices/:
{
"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/*" }
}
}
]
}
Observa las dos formas de ARN diferentes:
- Las operaciones en objetos usan ARN con
/*al final. s3:ListBucketes una operación en el bucket, así que usa ARN sin/*.
Confundir estos dos ARNs es la causa más común de errores AccessDenied. Hay otro detalle más: si falta s3:ListBucket, cuando el objeto no existe S3 devuelve 403 en lugar de 404. Este es un mecanismo deliberado de S3 que evita revelar si una key existe o no.
4.2. Bucket policy que obliga TLS
La bucket policy es ideal para establecer reglas que se apliquen a todos, incluyendo administradores:
{
"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" }
}
}
]
}
Este Statement es Deny, así que todas las requests por HTTP se rechazan. Ningún Allow puede anular esto. El SDK v2 usa HTTPS por defecto, así que tu aplicación no se verá afectada.
5. Compartir archivos con presigned URL
Los clientes necesitan descargar facturas, pero no quieres abrir el bucket al público ni transmitir cada archivo a través del servidor. Presigned URL resuelve exactamente esto. Es una URL firmada con las credenciales de la aplicación y solo se puede usar durante un período corto de tiempo.
Agrega estos dos métodos a 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();
}
Una clase de excepción simple:
public class ForbiddenFileAccessException extends RuntimeException {
public ForbiddenFileAccessException(long customerId, String key) {
super("Customer %d cannot access %s".formatted(customerId, key));
}
}
Cómo funciona el código:
- La línea de verificación
startsWithes la capa de autorización a nivel de aplicación. S3 solo sabe que tu aplicación puede leer todos losinvoices/*. S3 no sabe que el cliente 42 no puede ver facturas del cliente 43. Si omites esta línea, cualquiera que adivine la key puede descargar archivos de otros. signatureDurationestablece la fecha de expiración de la URL. Para descargas de archivos, unos minutos suelen ser suficientes.- Con presigned PUT, el cliente debe enviar exactamente el mismo
Content-Type: application/pdf. Si no, la firma no coincidirá.
La URL generada tiene esta forma:
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=...
El cliente carga directamente a S3 usando presigned PUT:
curl -X PUT -H "Content-Type: application/pdf" \
--upload-file invoice.pdf "<presigned-url>"
Nota: La presigned URL tiene los permisos de quién la firmó. Si el rol IAM de la aplicación pierde
s3:GetObject, la URL ya emitida también dejará de funcionar. El tiempo máximo de expiración es de 7 días. Al firmar con credenciales temporales del rol IAM, la URL expira aún más rápido, junto con la sesión del rol (típicamente unas pocas horas). No guardes presigned URLs en base de datos para uso a largo plazo. Guarda la key y genera una nueva URL cada vez que la necesites.
6. Pruebas con LocalStack
Agrega Testcontainers para ejecutar un S3 simulado en 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 ya gestiona la versión de Testcontainers, así que no necesitas especificar version.
@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);
}
}
Algunos detalles a notar:
forcePathStyle(true)permite acceder al bucket de la formahttp://localhost:4566/test-invoices/.... LocalStack necesita esto porque no puede resolver subdominios comotest-invoices.localhost.- La tercera prueba es la más importante en términos de seguridad. Asegura que el cliente 42 no pueda obtener una URL para archivos del cliente 43.
LocalStack no evalúa IAM policies en la versión gratuita. Por lo tanto, deberías verificar las policies reales usando el IAM Policy Simulator o en un entorno staging.
7. Errores comunes
AccessDeniedaunque tieness3:GetObject: ARN sin/*, nombre de bucket incorrecto, o una bucket policyDenyte está bloqueando. Si el bucket usa SSE-KMS, el rol también necesita permisokms:Decrypten la key KMS correspondiente.403en lugar de404cuando el objeto no existe: el rol no tienes3:ListBucket. Este es el comportamiento correcto de S3, no un bug.SignatureDoesNotMatchcon presigned PUT: el cliente envía unContent-Typediferente al de la firma, o el presigner está configurado con una región diferente a la del bucket.- Hacer público con ACL
public-read: conBucketOwnerEnforced, las requests con ACL se rechazan. Si realmente necesitas contenido público, por ejemplo imágenes de productos, coloca CloudFront frente al bucket y usa Origin Access Control. No desactives Block Public Access. - Crear un nuevo
S3Clientpara cada request: el cliente mantiene un connection pool y debe ser compartido. Decláralo como singleton bean como en la sección 2.2.
Nota: S3 no es un file system. No tienes operaciones “rename” o “append”. Cambiar nombre significa copiar a una key nueva y eliminar la antigua. Si necesitas escribir continuamente datos nuevos en un archivo, S3 no es la opción adecuada.
8. Conclusión
En este artículo, usaste AWS SDK v2 en Spring Boot para cargar, descargar y eliminar archivos en S3, con keys diseñadas según el customerId. También configuraste permisos en múltiples capas:
- Block Public Access y ACL deshabilitado para mantener el bucket siempre privado.
- IAM policy con mínimo privilegio para el rol de la aplicación.
- Bucket policy para reglas generales como obligar TLS.
- Verificación de propiedad a nivel de aplicación antes de emitir presigned URLs.
El punto más importante a recordar: S3 solo sabe qué puede hacer tu aplicación. Determinar qué usuarios pueden ver qué archivos sigue siendo responsabilidad de tu código. Por eso deberías tener pruebas que cubran el caso donde un usuario intenta acceder a archivos de otro.