Upload Authorization & Tenant Isolation

Direct-to-storage uploads move the bytes away from your servers, and with them the natural place where “is this user allowed to put this file here?” used to be checked. Authorization now has to be expressed in what you sign — which key, how many bytes, what type, for how long — and enforced by the storage service; in multi-tenant products it must also guarantee that no bug in any code path can put one customer’s file in another customer’s space.

This topic belongs to backend validation and cloud storage architecture. It sits between S3 presigned URL workflows, which produce the signed requests, and upload rate limiting and abuse protection, which limits how many of them anyone can obtain. On the delivery side, the same principles reappear in secure media delivery.

Prerequisites

  • [ ] An authentication layer that yields a verified user ID and tenant ID on every request.
  • [ ] Opaque, ARN-safe identifiers for users and tenants (no emails, no free text).
  • [ ] An object key layout with tenant and user as path segments, chosen by the server.
  • [ ] A signing role whose permissions you can narrow with session tags or session policies.
  • [ ] A configuration table for tenant storage placement (shared prefix, access point or dedicated bucket).
  • [ ] Automated tests that attempt cross-user and cross-tenant writes and expect them to fail.

How it works

Upload authorization answers five questions, and each has a best place to enforce it.

Who is asking? Authentication, in your main application — sessions, OAuth, API keys. Everything else starts from the verified identity it produces.

May they upload at all, and this much? Business rules — plan limits, quotas, file-type policy, account standing — checked by your API before any credential is issued. The answer is encoded in what gets signed: the declared size and type become signed fields.

Where may the bytes go? Decided by the server, never the client: a key derived from tenant, user and a fresh ID. Enforced twice — by the signature, which covers the exact key, and by IAM, which confines the signing credentials to the user’s or tenant’s prefix. Scoping upload keys per user with IAM policy variables builds the IAM half.

For how long? Short-lived capabilities: presigned URLs with minutes of validity, or upload tokens that authorise starting one upload and are exchanged for a session, as in issuing short-lived upload tokens with JWTs.

Which tenant’s space is it? A placement decision per tenant — shared prefix, access point or dedicated bucket — made in one resolver that every path uses, as in isolating tenants with bucket prefixes and access points.

Layers that authorise a direct-to-storage upload A request passes through authentication, which yields user and tenant IDs; the upload API, which applies quotas and type rules and chooses the key and placement; the signer, which assumes a role session tagged with the tenant and user; and storage, which verifies the signature and evaluates the tagged identity policy, access point policy and bucket policy before accepting bytes. Decide in the API, enforce in storage authenticate session / token → userId, tenantId upload API quota, type, size key + placement signer session tagged tenant + user storage signature + policies accept or 403 If the API builds the wrong key… …the tagged session cannot sign for it, or storage rejects it with 403. Application bugs fail closed instead of writing into someone else's space. Every arrow is a trust boundary; each layer re-checks what the previous one decided.
Business rules live in the API; boundaries live in storage policy, where a code bug cannot remove them.

Step-by-step implementation

Step 1: Derive identity once, from the authenticated request

Every downstream decision depends on the user and tenant IDs, so derive them in one place and pass a typed object, never raw request fields.

export interface Principal { userId: string; tenantId: string; roles: string[] }

const ID = /^[a-z]-[a-z0-9]{6,32}$/;

export function principalFrom(verifiedClaims: Record<string, unknown>): Principal {
  const userId = String(verifiedClaims.sub ?? "");
  const tenantId = String(verifiedClaims.tid ?? "");
  if (!ID.test(userId) || !ID.test(tenantId)) throw new Error("malformed identity");   // ARN-safe only
  return { userId, tenantId, roles: Array.isArray(verifiedClaims.roles) ? verifiedClaims.roles.map(String) : [] };
}

console.log(principalFrom({ sub: "u-8f3a2c1b", tid: "t-acme01", roles: ["member"] }));
// { userId: 'u-8f3a2c1b', tenantId: 't-acme01', roles: [ 'member' ] }

Step 2: Apply business rules and decide the key

The upload API checks everything that depends on your data — plan, quota, type policy — and produces a decision that includes the exact key.

import { randomUUID } from "node:crypto";
import type { Principal } from "./principal.ts";

export interface UploadDecision { key: string; maxBytes: number; contentType: string; ttlSeconds: number }

export async function decide(p: Principal, req: { size: number; type: string },
  quota: { usedBytes: number; limitBytes: number }, allowed: Set<string>): Promise<UploadDecision> {
  if (!allowed.has(req.type)) throw Object.assign(new Error("type not allowed"), { status: 415 });
  if (req.size <= 0 || quota.usedBytes + req.size > quota.limitBytes) {
    throw Object.assign(new Error("quota exceeded"), { status: 507 });
  }
  return {
    key: `tenants/${p.tenantId}/users/${p.userId}/${randomUUID()}/source`,
    maxBytes: req.size,
    contentType: req.type,
    ttlSeconds: 900,
  };
}

Quota checks at issue time are necessarily optimistic — several uploads can be in flight at once — so reserve the declared bytes when issuing and reconcile when uploads complete, as described in enforcing per-user storage quotas.

Step 3: Sign with credentials scoped to the principal

The signer assumes a role session tagged with the principal, and signs the decision. The role’s policy uses ${aws:PrincipalTag/tenantId} and ${aws:PrincipalTag/userId} in its resources, so the session can only sign for that principal’s prefix.

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 type { Principal } from "./principal.ts";
import type { UploadDecision } from "./decide.ts";

const sts = new STSClient({});

export async function sign(p: Principal, d: UploadDecision): Promise<string> {
  const { Credentials: c } = await sts.send(new AssumeRoleCommand({
    RoleArn: process.env.SIGNER_ROLE_ARN!,
    RoleSessionName: `up-${p.userId}`.slice(0, 64),
    DurationSeconds: 900,
    Tags: [{ Key: "tenantId", Value: p.tenantId }, { Key: "userId", Value: p.userId }],
  }));
  const s3 = new S3Client({ credentials: {
    accessKeyId: c!.AccessKeyId!, secretAccessKey: c!.SecretAccessKey!, sessionToken: c!.SessionToken! } });
  return getSignedUrl(s3, new PutObjectCommand({
    Bucket: process.env.UPLOAD_BUCKET!, Key: d.key, ContentType: d.contentType, ContentLength: d.maxBytes,
  }), { expiresIn: d.ttlSeconds });
}

In production, cache sessions per principal for most of their lifetime rather than assuming a role on every request.

Step 4: Record the grant

Store what was granted — key, owner, tenant, declared size and type, expiry — as a pending upload. The grant record is what completion checks against, what the sweeper expires, and what an audit reads when asking “who was allowed to write this object?”.

import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function recordGrant(p: { userId: string; tenantId: string }, d: { key: string; maxBytes: number; contentType: string; ttlSeconds: number }) {
  await db.query(
    `INSERT INTO upload_grants (storage_key, user_id, tenant_id, max_bytes, content_type, expires_at, status)
     VALUES ($1, $2, $3, $4, $5, now() + make_interval(secs => $6), 'pending')`,
    [d.key, p.userId, p.tenantId, d.maxBytes, d.contentType, d.ttlSeconds]);
}

Step 5: Enforce the same boundary everywhere else

Uploads are one path. Processing workers, download signers, support tools and search indexes all touch tenant data. Give each the same principal-scoped credentials and the same placement resolver, so there is no second, weaker path to the data.

export function workerPrincipalFromKey(key: string): { tenantId: string; userId: string } {
  const m = key.match(/^tenants\/([a-z]-[a-z0-9]{6,32})\/users\/([a-z]-[a-z0-9]{6,32})\//);
  if (!m) throw new Error(`key outside tenant layout: ${key}`);
  return { tenantId: m[1], userId: m[2] };
}

console.log(workerPrincipalFromKey("tenants/t-acme01/users/u-8f3a2c1b/3f0a/source"));
// { tenantId: 't-acme01', userId: 'u-8f3a2c1b' }

A worker that derives its principal from the key it was asked to process, and assumes a tagged session for it, cannot be tricked by a malformed event into writing another tenant’s outputs.

Every path to tenant data uses the same scoped identity Upload signing, processing workers, download signing and support tooling each derive a principal and assume a tagged role session scoped to that tenant, so all four paths are bounded by the same IAM policy. A shared bucket-wide role is used only by infrastructure automation, never in request paths. One boundary, four doors upload signer processing worker download signer support tooling tagged session: tenantId, userId policy: tenants/${tag}/… only Bucket-wide roles exist only for infrastructure automation, never in a request path.
Isolation is only as strong as the weakest path to the data, so every path uses the same scoped identity.

The threat model for direct uploads

It helps to be explicit about what this design defends against, because the controls only make sense relative to the threats.

A curious or malicious user tries to write outside their own space — overwriting another user’s avatar, planting a file in another tenant’s folder, or uploading far more than their plan allows. Server-chosen keys, signed lengths and IAM-scoped signing sessions stop all three, and quota reservations stop the fourth variant, many small uploads in parallel.

A leaked credential — a presigned URL in a log, a token in a screenshot, a browser extension reading network traffic — is used by someone else. Short lifetimes, single-use tokens and exact signed parameters bound what it can do to “upload one specific file of one specific size in the next few minutes”, which is rarely useful to an attacker.

A bug in your own code — the most likely of the three — builds the wrong key, forgets a check or reuses a function in a new context. The defence is that the storage service enforces the boundary independently of the code, so the bug produces a 403 instead of a breach. This is also why tests should attempt cross-boundary writes: the policies are the part of the system that must stay correct when the code is not.

A compromised internal component — a worker, a support tool, a CI job — is the hardest case. Scoping every component’s credentials to the tenant it is working for limits the blast radius to one tenant rather than the bucket; keeping bucket-wide roles out of request paths entirely means no single compromised service can read everything.

What this design does not defend against is malicious content — malware, illegal material, decompression bombs. Authorization decides who may put bytes where; server-side file validation and automated virus scanning integration decide whether those bytes are acceptable.

Auditing grants and access

Enterprise customers eventually ask two questions: “who could write to our space?” and “who did?”. Design for both from the start.

The first is answered by your grant records and your policies. Every issued upload URL or token corresponds to a row with principal, key, size, type and expiry, so “who was allowed to write this object” is a query. Pair it with policy exports — the signer role’s policy, access point policies, bucket policy — captured on every change, so you can show what the rules were at any point in time.

The second is answered by storage access logs. Enable CloudTrail data events (or S3 server access logs) for upload buckets; with session tags, each event carries the tenant and user of the session that signed the request, which makes per-tenant audit trails a filter rather than a forensic project. Retain them for as long as your contracts require, and keep them outside the account that the upload services run in, so a compromised service cannot erase its own tracks.

Configuration reference

Setting Type Default here Effect
Identifier format regex ^[a-z]-[a-z0-9]{6,32}$ Safe to substitute into ARNs and keys.
Key layout template tenants/{t}/users/{u}/{id}/source Every boundary is a prefix a policy can match.
Signed fields list key, Content-Length, Content-Type The URL accepts only the decided object.
URL / token TTL seconds 300–900 Long enough to start, short enough to be useless if leaked.
Session tags keys tenantId, userId Drive ${aws:PrincipalTag/…} in resources.
Missing-tag deny policy explicit Deny Untagged sessions can do nothing.
Bucket policy policy TLS only, writes only under tenants/ Guardrails for every principal.
Placement per tenant shared prefix by default Access point or dedicated bucket on requirement.
Grant record row pending until confirmed Audit trail and completion check.
Token replay store jti single-use A captured token cannot start a second upload.

Edge cases and gotchas

Path traversal in keys

If any part of the key comes from user input — a folder name, a filename — .., leading slashes, backslashes and URL-encoded variants can escape the intended prefix in code that later joins paths. Never build keys from user strings; store filenames as metadata. IAM scoping catches what slips through, but only if the check is on the final key, which is exactly what S3 evaluates.

Tokens that outlive permissions

A user removed from a tenant keeps any URL or token already issued until it expires. Short lifetimes bound that window; for immediate revocation, check a revocation list when upload sessions are created, and abort open multipart uploads belonging to removed users.

A “share with anyone” feature is a deliberate exception to isolation. Implement it as a separate grant (a signed, expiring download link or a share record your API checks), never by making objects public or moving them to a public prefix, which would bypass every policy above.

Cross-tenant deduplication

Deduplicating identical files across tenants saves storage but creates a covert channel: a tenant can learn that another tenant has a file by uploading it and observing that the upload “completed instantly”. Deduplicate within a tenant only, or make cross-tenant deduplication invisible in timing and responses.

Tenant deletion and offboarding

When a tenant leaves, their data must go — completely and provably. With a shared prefix, deletion is a batch operation over tenants/<id>/ plus every derived location (thumbnails, transcodes, search indexes, backups). With a dedicated bucket and key, it is emptying and deleting the bucket and scheduling the key for deletion, after which any copy you missed is unreadable. Either way, revoke the tenant’s sessions and access points first, so nothing writes new data while deletion runs, and record the completion for the customer.

Delegated uploads

Some products let one user upload on behalf of another — an assistant filing documents for a manager, a mobile app uploading for a kiosk account. Model it explicitly: the grant records both the acting user and the owner, the key lives in the owner’s space, and the signing session is tagged with the owner while the audit record keeps the actor. Never let a client simply name a different owner in the upload request; the delegation must be a relationship your API checks.

Administrative and support access

Support staff need to see customer files, occasionally. Give them a role that requires a ticket reference and a tenant tag, time-boxed through your identity provider, and log every access to a place the customer can audit. A standing bucket-wide support role is the most common isolation failure in practice.

Common isolation failures and their controls Path traversal from user filenames is prevented by server-generated keys and IAM scoping. Leaked URLs are bounded by short lifetimes and signed lengths. Workers writing the wrong tenant are prevented by deriving the principal from the key. Standing support access is replaced by time-boxed tagged roles with audit logs. Failure → control ../ in a user-supplied filename server keys + IAM prefix scope leaked presigned URL 15-min TTL + signed length/type worker writes another tenant's output principal derived from the key standing bucket-wide support role time-boxed, tagged, audited access
Each control turns a class of bug or misuse into a denied request rather than an exposed file.

Verification

Write isolation tests as a matrix and run it in CI against a staging account:

# For each (actor, target) pair, expect allow only on the diagonal.
for actor in u-aaaaaa01 u-bbbbbb02; do
  for target in u-aaaaaa01 u-bbbbbb02; do
    URL=$(node scripts/sign-as.mjs --user "$actor" --tenant t-acme01 --key "tenants/t-acme01/users/$target/test/source")
    code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT --data-binary 'x' -H 'Content-Length: 1' "$URL")
    echo "$actor$target: $code"
  done
done
# u-aaaaaa01 → u-aaaaaa01: 200
# u-aaaaaa01 → u-bbbbbb02: 403
# u-bbbbbb02 → u-aaaaaa01: 403
# u-bbbbbb02 → u-bbbbbb02: 200

Extend the matrix across tenants, across access points and dedicated buckets, and to worker and download roles. A green matrix is the evidence you show a customer’s security review, and a red cell in it is a release blocker, not a ticket for later.

Frequently Asked Questions

Is signing the exact key not enough on its own?

It is enough when the code choosing the key is correct. IAM scoping protects against the day it is not — a refactor, a new endpoint, a support script — by making the storage service refuse writes outside the principal’s prefix regardless of what was signed.

How many AssumeRole calls does this cost?

One per principal per session lifetime if you cache sessions. For most products that is a few calls per user per hour, well within STS limits; very high-volume issuers can use session policies on a smaller set of cached sessions instead.

What about GCS and Azure?

The same layering applies with different tools. On GCS, sign V4 URLs from a service account and use IAM Conditions on managed folders or object name prefixes for scoping; on Azure, issue user delegation SAS tokens scoped to a container or a blob path, with RBAC conditions on blob path for the signing identity. In every cloud the principle holds: decide in your API, sign narrowly, and let the storage service enforce the boundary.

Do I need all of this for a single-tenant app?

Per-user scoping still prevents one user overwriting another’s files, and short-lived signed URLs still matter. Tenant placement is the part you can skip until you have tenants.