Back to Resume
PrivateMarch 2025· 1 week

Enterprise Media Management & Secure URL Platform

A Secure Presigned URL Service for Direct-to-S3 Uploads

RoleCloud/Backend Engineer

An enterprise client needed backend services to upload and remove files in S3 without holding direct AWS credentials or routing large files through an application server. I built a small serverless service: a Lambda behind API Gateway that validates the requested file name and hands back a time-limited presigned S3 upload URL, secured with Cognito client-credentials auth, plus a matching delete route and a CloudFront read path. It gives callers a narrow, auditable door onto the bucket instead of shared AWS keys.

The problem

The client's backend services needed to write files into S3 and later serve them back out, but giving every calling service its own AWS access key does not scale and is hard to audit. Routing every file through a Lambda or an API server instead burns compute on data that is only passing through, and runs into payload size limits on larger files. What they needed was a single, narrow endpoint: a caller proves who it is once, asks for permission to write one specific file, and then talks to S3 directly for the actual transfer. Deletion needed the same shape. Reads needed to be fast and cacheable across many requests, which a fresh signed URL on every read works against.

Constraints

  • ·No direct AWS credentials for calling services, only Cognito-issued tokens.
  • ·Uploads must not pass file bytes through compute (Lambda or the API server).
  • ·A caller can only write the exact object it requested, not an arbitrary path in the bucket.
  • ·Reads of already-stored files need to be fast and cacheable, not signed per request.

Architecture

A client authenticates once against a Cognito user pool using the OAuth2 client-credentials grant, then calls API Gateway with the resulting bearer token and a target file name. A Lambda validates the name (rejects empty names and path-traversal characters), builds the full S3 key from an environment-configured prefix, and returns a presigned PUT URL scoped to that one key with a default 30-minute expiry. The client then uploads the file directly to S3, bypassing both API Gateway and the Lambda. Stored files are read back through a CloudFront distribution URL rather than another presigned link. A delete route sits behind the same API Gateway and Cognito auth, called the same way by a second client script, but its own Lambda handler is not present in this project's source.

  • Cognito user pool: issues M2M client-credentials tokens
  • API Gateway: /demo/presigned and /demo/delete routes, bearer-token authenticated
  • Lambda (presigned_url.py): validates the file name, builds the S3 key from an env-configured prefix, signs a PUT URL with a configurable expiry (default 1800s)
  • S3 bucket: object store, written to directly by the client using the signed URL
  • CloudFront: serves stored objects back out on read, not signed or expiring
  • Lambda (delete): referenced by the client contract only, handler not in this project's source

Upload and delete share one auth model (Cognito M2M) and one API Gateway, but diverge after that: uploads are signed then executed directly against S3, while delete is a plain authenticated API call whose handler internals were not traced here.

How one request flows

  1. 01The calling service requests a token from Cognito using a client id and secret, under the OAuth2 client-credentials grant.
  2. 02It calls the API with the token and the file name it wants to write.
  3. 03The Lambda rejects an empty name and rejects any name containing /, .., or \, so a caller cannot escape the configured upload prefix.
  4. 04The Lambda builds the full object key from the prefix and generates an S3 presigned PUT URL scoped to that one key, expiring by default in 30 minutes.
  5. 05The client PUTs the file straight to the presigned URL, with a content type guessed from the file extension. Neither the Lambda nor API Gateway see the file bytes.
  6. 06Once stored, the object is served through a CloudFront URL rather than a second presigned link, so repeated reads are cached and fast.

Engineering decisions

Sign the URL, never touch the bytes

What. The Lambda only ever generates a presigned PUT URL, it never receives the file itself.

Why. Keeps the Lambda from being a bottleneck or hitting a payload size limit on larger files.

Tradeoff. The Lambda cannot inspect file content before the write happens, only the requested key name, so any content-level check would need to run after the upload, not before it.

Validating the key is the one thing left to secure

What. The handler rejects empty file names and rejects /, .., and \ before building the S3 key.

Why. Once a URL is scoped to a single key, the requested key name is the only remaining attack surface.

Tradeoff. A stricter allowlist (e.g. a fixed character set) would be even tighter, but the blocklist chosen covers the concrete traversal patterns without adding friction for normal file names.

Machine-to-machine auth, not end-user login

What. Used Cognito's client-credentials grant, since the callers are backend services, not people in a browser.

Why. Fits the shape of who is actually calling the API.

Tradeoff. The resulting token carries no end-user identity, only the calling service's own client id, so per-user authorization has to live in the calling application, not in this service.

Asymmetric read and write paths

What. Writes go through a short-lived presigned URL. Reads go through CloudFront instead of another presigned URL.

Why. A presigned GET carries a unique query string per request and defeats CDN caching, which reads need for speed at volume.

Tradeoff. The read path relies on the bucket and distribution configuration for access control rather than a token, so a public CloudFront URL is only as private as that configuration makes it.

Bucket, prefix, and expiry from configuration, not code

What. The S3 bucket name, upload prefix, and URL expiry all come from environment variables.

Why. The same Lambda deploys against a different bucket or environment without a code change.

Tradeoff. None significant, a small decision that keeps the handler reusable.

Short, configurable expiry on write access

What. The presigned upload URL defaults to a 30 minute expiry, set through an environment variable.

Why. Bounds how long a leaked URL, in a log line or a proxy, stays useful to write with.

Tradeoff. Too short a window would fail slow uploads on poor connections, so it is configurable rather than fixed.

What changed along the way

  • The delete route was wired up behind the same API and auth model as the upload route, but its own Lambda handler was not part of what this project's source could confirm, so its internal validation is not something that can be claimed here.
  • No infrastructure-as-code or CI/CD configuration was included with what was delivered in this project, so the exact deployment pipeline was not traceable, only the runtime behavior of the Lambda and client scripts.

Security and reliability

  • ·No shared AWS credentials: callers authenticate against Cognito, never against AWS directly, and the Lambda is the only component holding S3 permissions.
  • ·Scoped, expiring writes: every presigned URL is valid for one exact key and expires by default in 30 minutes.
  • ·Input validation on every request: empty file names and path traversal attempts (/, .., \) are rejected before a key is ever built.
  • ·Fails loud on misconfiguration: a missing bucket environment variable returns a 500 with a logged error rather than silently failing.
  • ·Structured error responses: both validation and generation failures return a JSON error body with an appropriate status code, and every failure is logged server-side.

Metrics

1 key
per presigned URL, no bucket-wide write access
measured
30 min
default upload URL expiry, configurable via environment variable
measured
3 checks
on every file name before a key is built (empty, slash, traversal)
measured

What shipped

  • Delivered a working presigned-upload Lambda with input validation, environment-driven configuration, and structured error handling.
  • Verified the full auth-to-upload contract end to end with a client script: Cognito token, signed URL request, direct S3 PUT.
  • Verified the delete route's client-facing contract with a matching script, authenticated the same way.
  • Documented the CloudFront read-URL pattern used to serve stored files back out.

Lessons learned

  • ·A presigned URL only closes the AWS-credentials problem. Anything downstream of that, like protecting the reads, still needs its own access control decision.
  • ·Reads and writes do not want the same URL strategy. Writes want short-lived and single-use, reads want cacheable and stable.
  • ·Once a URL is scoped to a single key, input validation on the key name is the entire remaining attack surface for that endpoint, so it deserves the most attention.
  • ·Environment-driven configuration for the bucket, prefix, and expiry made the same handler portable across environments without touching code.

Tech stack

Backend
AWS Lambda (Python)Amazon API Gatewayboto3
Data & state
Amazon S3Amazon CloudFront
Ops & quality
Amazon Cognito (OAuth2 client-credentials grant)Python requests client scripts for auth and upload/delete verification