Scoping Upload Keys per User with IAM Policy Variables
Sign each user’s upload URLs with credentials that can only write under that user’s prefix: have your API call AssumeRole with a session tag (userId=u-42) or a session policy, and give the role a policy whose Resource uses arn:aws:s3:::uploads/users/${aws:PrincipalTag/userId}/* — so even if application code builds the wrong key, S3 refuses to accept a presigned request for another user’s path.
Most upload systems enforce “users can only write their own files” in application code: the API decides the key, then signs it with a role that can write anywhere in the bucket. That works until someone refactors the key builder, a path-traversal bug lets ../ into a filename, or a support tool reuses the signing function with the wrong user ID — at which point one bug is a cross-tenant write. Pushing the rule down into IAM means the storage service itself enforces it, and application bugs fail closed. This page belongs to upload authorization and tenant isolation in backend validation and cloud storage architecture. The URLs it signs are the ones from generating secure presigned URLs with AWS SDK v3.
When to use this approach
- Many users or tenants upload into one bucket, and a cross-user write would be a security incident.
- Your API signs presigned URLs or hands out temporary credentials for direct uploads.
- You want a guarantee that holds even when application code is wrong.
Prerequisites
- An IAM role for signing uploads (
UploadSigner) that your API can assume, with a trust policy allowingsts:TagSession. @aws-sdk/client-stsand@aws-sdk/client-s3v3.- A key layout with the user or tenant as a path segment:
users/<userId>/…ortenants/<tenantId>/users/<userId>/…. - Identifiers that are safe in ARNs: opaque IDs such as
u-8f3a2c, never email addresses or display names.
Where the check happens
Implementation
The role’s trust and permissions policies:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/api-service" },
"Action": ["sts:AssumeRole", "sts:TagSession"],
"Condition": { "StringLike": { "aws:RequestTag/userId": "u-*" } }
}]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WriteOwnPrefixOnly",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"],
"Resource": "arn:aws:s3:::media-uploads/users/${aws:PrincipalTag/userId}/*"
},
{
"Sid": "NoTagNoAccess",
"Effect": "Deny",
"Action": "s3:*",
"Resource": "*",
"Condition": { "Null": { "aws:PrincipalTag/userId": "true" } }
}
]
}
The API assumes the role per request (cached briefly per user) and signs with the scoped credentials:
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const sts = new STSClient({});
const ROLE_ARN = "arn:aws:iam::123456789012:role/UploadSigner";
const BUCKET = "media-uploads";
const cache = new Map<string, { client: S3Client; expires: number }>();
async function s3For(userId: string): Promise<S3Client> {
if (!/^u-[a-z0-9]{6,32}$/.test(userId)) throw new Error("invalid user id"); // safe in an ARN
const hit = cache.get(userId);
if (hit && hit.expires > Date.now() + 5 * 60_000) return hit.client;
const { Credentials } = await sts.send(new AssumeRoleCommand({
RoleArn: ROLE_ARN,
RoleSessionName: `upload-${userId}`.slice(0, 64),
DurationSeconds: 3600,
Tags: [{ Key: "userId", Value: userId }],
}));
const client = new S3Client({
credentials: {
accessKeyId: Credentials!.AccessKeyId!,
secretAccessKey: Credentials!.SecretAccessKey!,
sessionToken: Credentials!.SessionToken!,
},
});
cache.set(userId, { client, expires: Credentials!.Expiration!.getTime() });
return client;
}
export async function signUpload(userId: string, contentType: string, size: number): Promise<{ key: string; url: string }> {
const key = `users/${userId}/${randomUUID()}/source`;
const s3 = await s3For(userId);
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: BUCKET, Key: key, ContentType: contentType, ContentLength: size,
}), { expiresIn: 900 });
return { key, url };
}
console.log(await signUpload("u-8f3a2c1b", "image/jpeg", 48231));
Line-by-line on the decisions that matter
${aws:PrincipalTag/userId}inResource. IAM substitutes the session’s tag value when evaluating the request. The same role serves every user, yet each session can only touch its own prefix. No per-user roles, no policy per user.sts:TagSessionand the trust-policy condition. Only your API may tag sessions, and only with values matching the expected format. Without the condition, a compromised caller could tag itselfu-*with any ID; with it, at least malformed values are refused.- The explicit
Denywhen the tag is missing. If code ever uses the role without a tag, the policy variable resolves to nothing and aResourcelikeusers//*could match unexpectedly. The deny closes that path. - Opaque user IDs validated before use. Policy variables are substituted literally. An ID containing
*or/would widen the resource pattern; restricting IDs to a safe alphabet keeps the substitution exact. - Caching sessions per user.
AssumeRoleadds tens of milliseconds and has rate limits. Reusing a session for most of its hour keeps signing fast. Note that presigned URLs expire when the session does — why presigned URLs expire early with temporary credentials covers the interaction. ContentLengthin the signature. Scoping controls where; the signed length controls how much. Both are needed.
Layering with a bucket policy
A matching bucket policy adds guardrails that hold for every principal, including roles you forget about:
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "TLSOnly", "Effect": "Deny", "Principal": "*", "Action": "s3:*",
"Resource": ["arn:aws:s3:::media-uploads", "arn:aws:s3:::media-uploads/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } } },
{ "Sid": "SignerWritesOnlyUserPrefixes", "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject",
"NotResource": "arn:aws:s3:::media-uploads/users/*",
"Condition": { "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::123456789012:role/UploadSigner" } } }
]
}
Tenants, not just users
Multi-tenant products usually need two levels: a tenant boundary that must never be crossed, and user prefixes inside it. Tag sessions with both (tenantId, userId) and scope with tenants/${aws:PrincipalTag/tenantId}/users/${aws:PrincipalTag/userId}/*. Tenant administrators who may write anywhere in their tenant get a separate role scoped to tenants/${aws:PrincipalTag/tenantId}/*. The same approach extends to S3 Access Points per tenant, covered in isolating tenants with bucket prefixes and access points.
Keep the tag values authoritative. They should come from your authentication layer (the verified session or token), never from request parameters. The whole scheme rests on the API tagging the session with the identity it verified; a code path that copies a user ID from the request body into the tag reintroduces exactly the bug IAM was meant to catch.
Testing policies before they reach production
IAM policies with variables are easy to get subtly wrong, and the failure mode — either everything is denied or, worse, everything is allowed — only shows up at runtime. Test them the same way you test code. The IAM policy simulator (aws iam simulate-principal-policy) accepts context keys, so you can evaluate the signer role’s policy with aws:PrincipalTag/userId=u-42 against users/u-42/x (expect allow) and users/u-99/x (expect deny) in CI without touching a bucket. Pair that with a small integration test that assumes the role in a staging account and attempts real writes inside and outside the prefix.
Also test the negative paths that are easy to forget: a session without a tag, a tag with an unexpected format, a key containing .. or a double slash, and a multipart upload whose CreateMultipartUpload targets another user’s prefix. Every one of these should return AccessDenied. IAM Access Analyzer’s policy validation catches syntax mistakes and overly broad resources before deployment, and its unused-access findings show if the signer role still holds permissions it no longer needs.
Configuration gotchas
AccessDenied: … is not authorized to perform: sts:TagSession. The role’s trust policy allows sts:AssumeRole but not sts:TagSession. Both actions must be allowed for tagged sessions.
Uploads fail with 403 for every user after deployment. The policy variable name does not match the tag key — tags are case-sensitive in values but aws:PrincipalTag/UserId and userId are different keys in practice. Use one spelling everywhere and test with aws sts assume-role --tags.
Presigned URLs die after 60 minutes regardless of expiresIn. They were signed with assumed-role credentials that expire in an hour. Sign on demand, shortly before use, rather than extending expiresIn.
PackedPolicyTooLarge from AssumeRole. Too many or too long session tags or session policies. Keep tags to IDs and use managed policies on the role rather than large inline session policies.
Verification
# Assume the role as u-42 and try to write inside and outside the prefix.
CREDS=$(aws sts assume-role --role-arn arn:aws:iam::123456789012:role/UploadSigner \
--role-session-name test --tags Key=userId,Value=u-42 --query Credentials --output json)
export AWS_ACCESS_KEY_ID=$(jq -r .AccessKeyId <<<"$CREDS") AWS_SECRET_ACCESS_KEY=$(jq -r .SecretAccessKey <<<"$CREDS") \
AWS_SESSION_TOKEN=$(jq -r .SessionToken <<<"$CREDS")
aws s3 cp tiny.jpg s3://media-uploads/users/u-42/test.jpg # upload: ok
aws s3 cp tiny.jpg s3://media-uploads/users/u-99/test.jpg # AccessDenied
aws s3 cp tiny.jpg s3://media-uploads/other/test.jpg # AccessDenied
Add the same three cases as an automated test against a staging bucket; a policy regression then fails CI instead of production.
Frequently Asked Questions
Is this necessary if the API already builds keys correctly?
It is defence in depth: the API should build keys correctly, and IAM ensures that when it does not, the result is a failed upload rather than a breach. The cost is one AssumeRole per user per hour.
Can I use Cognito identities instead of session tags?
Yes. With Cognito Identity Pools, ${cognito-identity.amazonaws.com:sub} plays the same role in the policy. Session tags work with any identity provider and keep the API in control of signing.
Does this apply to reads?
The same pattern scopes s3:GetObject for private downloads. For media served through a CDN, access is usually enforced with signed URLs or cookies at the edge instead, as in secure media delivery.