Multi-tenant direct S3 uploads with Cognito Identity Pools and IAM principal tags
Sometimes we just need frontend to simply place files into S3, so routing every upload through API Gateway and maybe Lambda adds extra latency, extra cost and extra code to maintain. Also there is API upload size limit of 10MB.
In this post, I will use a cleaner approach: direct FE-to-S3 uploads using:
- Cognito User Pool for authentication
- Cognito Identity Pool for temporary AWS credentials
- IAM principal tags for per-customer S3 prefix isolation
The goal is:
- User logs in through Cognito
- Frontend exchanges the Cognito token for temporary AWS credentials
- Browser uploads directly to S3
- IAM ensures that each user can only upload into their own customer-specific prefix
Why not just upload through the backend?
Classic flow where FE calls API Gateway which then triggers Lambda function that is doing uploads to S3 works but it has some drawbacks:
- Backend becomes an upload proxy
- Large files create unnecessary Lambda/API traffic
- You need more backend code for something S3 already does very well
- Scaling and retry behavior become more complicated than necessary
User Pool vs Identity Pool
Cognito User Pool is responsible for authentication and it gives you token after login.
Cognito Identity Pool is responsible for AWS access which means it takes a Cognito-authenticated identity and returns temporary AWS credentials tied to an IAM Role.
So the flow is:
- user logs in via Cognito User Pool
- frontend receives Cognito tokens
- frontend uses the token with Cognito Identity Pool
- Identity Pool returns temporary AWS credentials
- frontend uses those credentials to upload directly to S3
Multi-tenant S3 prefix
There might be use case when you do not want every authenticated user to upload anywhere in the bucket or we can say you need per-customer isolation, so you want something like this:
- customer cust1 can upload only into s3://bucket/cust1/…
- customer cust2 can upload only into s3://bucket/cust2/…
This is where IAM principal tags become useful. Instead of hardcoding many roles or policies per customer, you can:
- store customerName as a Cognito custom attribute
- expose it in the Cognito token
- map it into a principal tag in the Identity Pool
- use that principal tag inside the IAM policy
That lets one IAM role dynamically restrict access based on the authenticated user.
Cognito User Pool custom attribute
To make per-customer prefix isolation work, the authenticated identity needs a customer-specific value that can later be turned into an IAM principal tag. If you already have a Cognito User Pool, the important part is that users have a custom attribute such as customerName.
A minimal Terraform example looks like this:
resource "aws_cognito_user_pool" "this" {
name = "test-user-pool"
schema {
name = "customerName"
attribute_data_type = "String"
developer_only_attribute = false
mutable = true
required = false
string_attribute_constraints {
min_length = 0
max_length = 2048
}
}
}
This does not create a token by itself. It defines a user attribute in Cognito.
Later, when the user logs in, Cognito can include that attribute in the ID token as:
custom:customerNameSAML attribute mapping
If your users authenticate through an external SAML provider, Cognito can map incoming SAML attributes into Cognito custom attributes.
For example:
resource "aws_cognito_identity_provider" "saml" {
user_pool_id = aws_cognito_user_pool.this.id
provider_name = "CAS-SSO"
provider_type = "SAML"
provider_details = {
MetadataFile = file("${path.module}/saml/cas.xml")
}
attribute_mapping = {
"custom:customerName" = "Customer1"
"username" = "User"
"email" = "Email"
}
}
This means:
- the external SAML provider sends Customer1
- Cognito stores it as custom:customerName
- the ID token later contains custom:customerName
provider_details is where the SAML provider metadata is configured. In this example Terraform reads the IdP metadata XML from a local file and passes it to Cognito when creating SAML Identity Provider.
That mapping is the bridge between your identity provider and your AWS authorization model.
Creating the Identity Pool
Before creating the Identity Pool, the frontend application needs a Cognito User Pool client.
resource "aws_cognito_user_pool_client" "frontend" {
name = "frontend-app-client"
user_pool_id = aws_cognito_user_pool.this.id
generate_secret = false
}Next, we connect the Cognito User Pool to an Identity Pool so authenticated users can exchange Cognito tokens for temporary AWS credentials.
resource "aws_cognito_identity_pool" "this" {
identity_pool_name = "example-identity-pool"
allow_unauthenticated_identities = false
cognito_identity_providers {
provider_name = "cognito-idp.eu-central-1.amazonaws.com/${aws_cognito_user_pool.this.id}"
client_id = aws_cognito_user_pool_client.frontend.id
server_side_token_check = true
}
}A few important details:
- allow_unauthenticated_identities = false means only logged-in users can get AWS credentials
- server_side_token_check = true is a better default for sensitive flows
- client_id points to the Cognito app client used by the frontend
Mapping Cognito claims into principal tags
Next, we tell the Identity Pool to convert the Cognito claim into an AWS principal tag.
resource "aws_cognito_identity_pool_provider_principal_tag" "this" {
identity_pool_id = aws_cognito_identity_pool.this.id
identity_provider_name = "cognito-idp.eu-central-1.amazonaws.com/${aws_cognito_user_pool.this.id}"
use_defaults = false
principal_tags = {
customerName = "custom:customerName"
}
}This is the key line:
customerName = "custom:customerName"
It means:
- read custom:customerName from the Cognito token
- create an AWS principal tag called customerName
If the user logs in with custom:customerName = acme, the session effectively gets:
aws:PrincipalTag/customerName = customer1
IAM role for authenticated users
The Identity Pool needs an IAM role for authenticated users.
data "aws_iam_policy_document" "assume_authenticated_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = ["cognito-identity.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "cognito-identity.amazonaws.com:aud"
values = [aws_cognito_identity_pool.this.id]
}
condition {
test = "ForAnyValue:StringLike"
variable = "cognito-identity.amazonaws.com:amr"
values = ["authenticated"]
}
}
statement {
effect = "Allow"
actions = ["sts:TagSession"]
principals {
type = "Federated"
identifiers = ["cognito-identity.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "cognito-identity.amazonaws.com:aud"
values = [aws_cognito_identity_pool.this.id]
}
}
}
resource "aws_iam_role" "authenticated" {
name = "example-frontend-uploads-authenticated-role"
assume_role_policy = data.aws_iam_policy_document.assume_authenticated_role.json
}The sts:TagSession permission is important because the S3 authorization depends on principal tags attached to the temporary AWS session, not just on the Cognito token itself.
That basically means:
- Cognito provides the claim, such as custom:customerName
- Identity Pool maps that claim into a principal tag
- AWS must then be allowed to attach that tag to the assumed role session
Without sts:TagSession, the role could still be assumed, but the session tags needed for the S3 prefix restriction would not be available.
Restricting S3 access by customer prefix
Now we can write one IAM policy that dynamically restricts uploads.
resource "aws_iam_policy" "frontend_uploads" {
name = "example-frontend-uploads-policy"
description = "Allow authenticated users to upload files only into their customer-specific prefix"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "arn:aws:s3:::example-frontend-uploads/$${aws:PrincipalTag/customerName}/*"
},
{
Effect = "Allow"
Action = ["kms:GenerateDataKey"]
Resource = aws_kms_key.shared.arn
}
]
})
}This is the entire point of the pattern:
"arn:aws:s3:::example-frontend-uploads/$${aws:PrincipalTag/customerName}/*"
For one user that might resolve to:
arn:aws:s3:::example-frontend-uploads/customer1/*For another user:
arn:aws:s3:::example-frontend-uploads/customer2/*Same role, same policy, different runtime behavior.
Finally, attach the role to the Identity Pool:
resource "aws_cognito_identity_pool_roles_attachment" "this" {
identity_pool_id = aws_cognito_identity_pool.this.id
roles = {
authenticated = aws_iam_role.authenticated.arn
}
}*When the frontend exchanges Cognito tokens for temporary AWS credentials through the Identity Pool, it should use the ID token, not the access token. Because this upload authorization model depends on the custom claim:
custom:customerNameThat claim is typically present in the ID token, because the ID token describes the user and their attributes.
The access token is primarily meant for API authorization, not for carrying the full user profile shape used in IAM principal tag mapping.
If the frontend sends the wrong token, the principal tag may be empty and the S3 upload will fail with AccessDenied.
Summary
Each part of the setup has a specific role:
- User Pool: identifies who the user is
- Identity Pool: gives the frontend temporary AWS credentials
- Custom attribute: stores which customer or tenant the user belongs to
- Principal tags: carry that tenant information into IAM
- IAM policy: restricts S3 uploads to the correct customer-specific prefix
- S3 bucket: is the final destination where the frontend uploads files directly
For this solution to work correctly, the components need to be integrated:
- the User Pool must contain a custom attribute such as customerName
- if an external IdP is used, it must map its own user attribute to custom:customerName
- the frontend app client must be configured in the User Pool
- the Identity Pool must be connected to that User Pool and app client
- the Identity Pool principal tag mapping must read custom:customerName
- the IAM role for authenticated users must be attached to the Identity Pool
- the IAM policy must use ${aws:PrincipalTag/customerName} to enforce prefix-based access
- the frontend must use the ID token when exchanging Cognito tokens for temporary AWS credentials through the Identity Pool
Conclusion
Cognito User Pools handle authentication, but for direct browser-to-S3 uploads they are only part of the solution. By combining a User Pool, an Identity Pool, a mapped custom attribute, IAM principal tags, and a prefix-based S3 policy, we can let the frontend upload directly to S3 while still enforcing tenant-specific boundaries. This keeps the upload flow simple and removes the need for a backend upload proxy without giving the browser broad access to the bucket.







