Serverless direct-to-S3 uploads — JWT authorization, presigned URLs, and access-control scoping

Marcin Wypyszyński 20 July 2026 8 min read

A recurring requirement: let authenticated clients upload files — sometimes multi-gigabyte ones — through an HTTP API, with per-user access control and an auditable validation workflow. The naive implementation proxies the file bytes through the compute tier (a Lambda function behind API Gateway). It works for small payloads and becomes a liability as files grow: you pay for compute time proportional to transfer, you hit request-size limits, and a single upload can tie up an invocation for minutes.

The better pattern keeps bytes off the compute tier entirely. The API issues a short-lived S3 presigned URL, and the client transfers the file directly to S3. Compute is involved only in authorization and URL signing — operations measured in milliseconds and kilobytes, regardless of whether the file is 1 MB or 50 GB. This article walks through that design, the authorization model, and the access-control mistake that is easy to make along the way.

Two responsibilities, two Lambdas

The request path separates authentication from the business operation:

Client (JWT in Authorization header) → API Gateway → Authorizer Lambda (validate JWT, check role, return IAM policy) → Handler Lambda (sign a presigned URL) → client uploads directly to S3
  • The authorizer Lambda is a custom API Gateway authorizer. It validates the JWT against the identity provider's OIDC configuration (issuer, signature, expiry), checks for a required role claim, and returns an IAM policy that either allows or denies the call. API Gateway caches the decision for the token's lifetime, so the validator does not run on every request.
  • The handler Lambda runs only after authorization succeeds. It generates a presigned URL (AWS Signature V4, configurable TTL) and returns it. It never sees the file content.

Authentication is not authorization

The JWT proves who the caller is and that they hold a required role. That is authentication plus a coarse capability check — it does not, by itself, decide which objects the caller may act on. Conflating the two is the classic failure mode, and this system shipped it in an early version: list and download endpoints returned every object in the bucket to any authenticated caller. A valid token for user A could enumerate and download user B's files. This is broken object-level authorization (BOLA) — consistently near the top of the OWASP API Security list, and rarely caught by tests that only assert "authenticated caller gets 200".

The fix is structural: every object is stored under an owner-scoped key prefix, uploads/{user_id}/…, derived server-side from the token — never from client input. Read and write operations are then constrained to the caller's own prefix (plus, for reads, anything explicitly shared with them; see below).

OperationAuthorization rule
Upload / delete / set statusOwner only — key must sit under the caller's uploads/{user_id}/ prefix
ListCaller's own objects plus objects shared with them; never the whole bucket
DownloadOwner, or a user explicitly granted read access
Share / revokeOwner only
The lesson generalizes beyond this API: authentication answers "who is calling," authorization answers "may they touch this object." A token check at the edge satisfies the first and silently skips the second. Object-level ownership has to be enforced in the handler, against a server-derived identity.

State without a database: S3 object tags

Two features need per-object metadata: a validation workflow (pendingvalidated / rejected) and read-only sharing. Rather than introduce a database for what is inherently object-scoped state, both are stored as S3 object tags on the object itself — so the metadata travels with the object, versions with it, and disappears when it is deleted.

Object tags are a constrained medium, and the constraints shape the design:

  • Sharing is a shared_with tag holding a plus-separated list of user IDs — comma is not a valid character in an S3 tag value, so the usual delimiter is out.
  • A tag value is capped at 256 characters. The API enforces this and rejects a grant that would overflow it (returning the current count), which with ~10-character IDs works out to roughly 20 grantees per object — a limit worth knowing before it surprises you in production.
  • Updating one tag must not clobber the others. Setting validation status is therefore a read-modify-write on the full tag set, preserving shared_with — the same care any concurrent-tag update requires.
  • Grants are idempotent: re-granting an existing user or revoking a missing one returns the current state rather than erroring.

The result is a sharing-and-validation model with no additional infrastructure to run, back up, or keep consistent with the bucket. It is not free of trade-offs — tags are not a query engine, and at large grantee counts or complex ACLs a real datastore wins — but for object-scoped flags and a short grantee list, tags are the lower-operational-cost choice.

Large files: multipart, still direct

A single presigned PUT caps at 5 GB (an S3 limit). Beyond that, the client runs a multipart upload — still entirely direct to S3: an initiate call opens a session, each part is uploaded via its own presigned URL (sign-part), and a complete call finalizes with the part ETags; an abort path cancels a session and prevents orphaned parts from lingering (and billing). The compute tier signs URLs and brokers session IDs; it still never carries the payload.

Why this is cheap

Because the bytes bypass compute, cost tracks API calls and storage rather than transfer volume. Signing a URL for a 50 GB file costs the same as for a 50 KB file. For an order-of-magnitude sense: roughly 10,000 uploads totalling ~1 GB a month lands around $0.08/month across API Gateway, Lambda, and S3 — the transfer itself is a direct S3 PUT, not a metered pass-through. The dominant cost at scale is storage and egress, which you would pay regardless of the API in front of it.

Hardening details worth copying

  • Client-controlled filename, server-controlled key. The client names the file; the backend prepends the owner prefix and sanitizes the name to [A-Za-z0-9._-], so a crafted filename cannot escape the prefix or inject a path.
  • Presigned URLs are short-lived and pinned to a signature version and region; an expired or replayed URL simply fails.
  • The bucket blocks public access, is encrypted at rest, and versioned — with non-current versions expiring automatically so overwrites don't accumulate cost.
  • CORS defaults to * for convenience; production locks it to known origins. A default that is wrong for production is a default worth changing deliberately.

Generalizable takeaways

  • Don't proxy bytes through compute. Presigned URLs move the transfer to the object store; the compute tier only authorizes and signs — cheaper, and unbounded by request-size limits.
  • Authentication ≠ authorization. A valid token is not a licence to touch every object. Enforce object-level ownership in the handler, against a server-derived identity, not client input.
  • Match state storage to the state. Object-scoped flags and short ACLs fit S3 tags and need no database — provided you respect the medium's limits (delimiters, size caps, read-modify-write).
  • Design for the large case. Multipart keeps big files off the compute path too; the abort path is what stops orphaned parts from quietly billing you.

The whole upload service is a Terraform module: an API Gateway, two Lambda functions, and an S3 bucket, with authorization enforced at the edge and object ownership enforced in the handler. The design principle is the same one that runs through the rest of my cloud work — keep the expensive path (bytes, compute) off the hot path, and enforce correctness where the decision actually is, not one layer too early.

Designing something similar?

Serverless upload/download APIs, JWT authorization, and getting object-level access control right on AWS are among the areas I work in.

See services Email me