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

  1. An IAM role for signing uploads (UploadSigner) that your API can assume, with a trust policy allowing sts:TagSession.
  2. @aws-sdk/client-sts and @aws-sdk/client-s3 v3.
  3. A key layout with the user or tenant as a path segment: users/<userId>/… or tenants/<tenantId>/users/<userId>/….
  4. Identifiers that are safe in ARNs: opaque IDs such as u-8f3a2c, never email addresses or display names.

Where the check happens

Application-only scoping versus IAM-enforced scoping With application-only scoping, the API builds a key and signs it with a role that can write the whole bucket, so a bug that builds another user's key produces a working URL. With IAM-enforced scoping, the API assumes a role session tagged with the user ID and the role's policy only allows that user's prefix, so a wrong key produces a URL that S3 rejects with 403. Same bug, two outcomes app decides, role allows all bug builds users/u-99/… signed with uploads/* S3: signature valid 200 — cross-user write one code bug = one incident session tagged userId=u-42 bug builds users/u-99/… signed with users/${tag}/* S3: not allowed for u-42 403 AccessDenied the bug fails closed A presigned URL can never grant more than the credentials that signed it had.
Pushing the rule into IAM turns a data-exposure bug into a failed upload.

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} in Resource. 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:TagSession and 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 itself u-* with any ID; with it, at least malformed values are refused.
  • The explicit Deny when the tag is missing. If code ever uses the role without a tag, the policy variable resolves to nothing and a Resource like users//* 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. AssumeRole adds 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.
  • ContentLength in the signature. Scoping controls where; the signed length controls how much. Both are needed.

Layering with a bucket policy

Three policy layers evaluated on every upload A presigned upload request is evaluated against the signing role's identity policy, which allows only the tagged user's prefix, the bucket policy, which denies non-HTTPS requests and any write outside users prefixes, and the object size and content type bound into the signature. All three must allow the request. All layers must agree before S3 accepts the bytes identity policy users/${tag}/* only deny if untagged per-session scope bucket policy deny non-HTTPS deny writes outside users/ bucket-wide guardrail signature exact key and length expiry, content type per-request scope An explicit Deny anywhere wins; an Allow is needed in the identity policy. The signature adds the per-request limits. Any one layer failing produces 403 — which is the behaviour you want for a bug.
Identity scoping stops cross-user writes, the bucket policy stops everything else, and the signature bounds each request.

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.

Key hierarchy with tenant and user scopes The bucket contains a tenants prefix. Under it, each tenant has its own prefix, and under each tenant, users have their own prefixes. A user session tagged with tenant and user IDs can write only its user prefix. A tenant admin session tagged with the tenant ID can write anywhere in its tenant. Neither can cross into another tenant. Two tags, two scopes, one hard boundary tenants/ t-acme/ (admin scope) users/u-42/ (user scope) users/u-57/ t-globex/ users/u-11/ no acme session reaches here
User sessions write one leaf, admin sessions one subtree; the tenant boundary is enforced by the tag, not by code paths.

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.