Secure S3 Images with Clerk
Use Clerk auth from Lambda-at-Edge to secure your S3 images, and protect uploads, without wrecking your cache. In other words, images go brrr... securely.
Kevin Wang
So you have an application and you have the following requirements:
- Only authenticated users are allowed to upload images.
- Only authenticated users can view uploaded images.
Facebook is an example of an app that satisfies #1, but not number #2. Only authenticated users can upload images, but once they're uploaded the assets that are served from their CDN1 are unprotected (as far as I can tell). Anyone that has a link to your images can access them.
Whether or not that is by design is not relevant to this post. I'm simply using this as an example to illustrate the problem.
This write up walks through one approach to securing both image uploads and reads for an application. This leverages Clerk's short-lived JWT's for highly ephemeral and secure access.
AWS signed URLs, are another way to achieve this, but in my opinion, that approach is substantially more complicated.
I'll focus on my approach which is both reasonably simple and secure. If you've spent any time securing traditional web application backends with "auth middlewares" that verify bearer tokens in requests, this implementation should feel very familiar, and the authentication requirements, not to be confused with authorization checks, should be identical.
If you see any outstanding flaws or vulnerabilities, please reach out and let me know!
The stack
The following tech stack will be used:
- AWS S3 for image asset storage
- AWS Lambda@Edge for authorization business logic
- AWS CloudFront for compute at edge and exclusive access to the S3 bucket
- Clerk for stateless authenication token generation.
- Any client side application that is capable of making HTTP requests. I'll use Expo but this piece is ultimately fungible.

Securing S3
S3 is the defacto object storage option. AWS makes it super easy to upload files directly to your bucket's URL via a simple PUT request2. However, in this day and age, no one should be creating publicly accessible buckets as it violates the principle of least priviledge in infosec, and that can have severe consequences depending on the sensitivity of the data being stored 3. S3 in fact has since changed their posture to disallow public access by default on newly created buckets4.
So, how do you grant limited access to S3 assets? Here's the short, and I'll dive into the details right after.
- Use a private S3 bucket
- Put a CloudFront distribution infront of the bucket
- Entrust only CloudFront with permissions on the S3 bucket
- Use Clerk to generate short-lived JWT's for all image uploads and reads
- Use Lambda to verify bearer JWT's in incoming requests to CloudFront
- Store Cloudfront asset paths in your primary database
- Done
Trust CloudFront
We already went over the fact that S3 is not public by default. So CloudFront will be the only entity that we entrust with access to our S3 bucket. This can be accomplished through a bucket policy.
When creating a CloudFront distribution in the web UI, if you select S3 as an origin, CloudFront will conveniently generate the following JSON trust policy and give you a somewhat noisy notification to navigate to S3 bucket permissions to add it to the bucket's permissions.
Gotcha: Only reads (
s3:GetObject
) are provided by default, so writes (s3:PutObject
) must be added manually, hence the diff above.
JWT verification at edge
Now we're ready to leverage some edge compute (yay buzzwords) to verify Clerk JWT's.
CloudFront allows for four points in the request lifecycle where you can optionally deploy a Lambda@Edge function to:
- Viewer Request
- Viewer Response
- Origin Request
- Origin Response

Our "authorizer" function will be run on viewer requests, essentially as early as possible, before the request even reaches the cache, and well before it is sent to the origin.
Authenticate then cache!
The neat part here is that the authorization header, which is a highly ephemeral token, is only used during the viewer request authorization and is otherwise not a part of the cache key.
This means that subsequent authenticated requests for the same image, but with fresher tokens, will read from the Cloudfront edge cache rather than going all the way to the origin bucket.
You can simultaneously omit the authorization header from the cache key via the CloudFront cache policy settings, and also doubly delete it via the Lambda@Edge function, as seen in the lambda code below.
The lambda code
This is going to be a bit of a boring how-to for setting up the Lambda@Edge function, which honestly is plagued with odd gotchas and limitations.
The lambda function must use x86_64
architecture since Lambda@Edge only supports it, and not arm64
.
This is the extent of the authentication logic, jose.jwtVerify() does the heavy lifting,and the rest is intentionally dumb.
The door is fully open to implementing more complex logic like checking additional claims in the JWT, and checking the URL structure, but that is ultimately up the implementer and the application requirements. This basic implementation is generally secure at a minimum, and should serve as an unopinionated basis for layering on more complex authorization logic, if needed.
Uploading the Lambda function
This is my unsophisticated process for uploading the Lambda function using zip
and the aws
CLI.
And this is what the project structure looks like — essentially what is being zipped up.
I'd like to know what the slicker Terraform way to do this is, but these days I don't care enough to use Terraform for personal work anymore because I know I will spend 10x more time figuring out "how do I terraform this" rather than building something of value.
Deploying to Lambda@Edge
The lambda function's role needs to trust the edgelambda.amazonaws.com
service principal 5,
or else it simply won't be deployable to Lambda@Edge.
After this is in place the lambda should be deployable to CloudFront from the Lambda UI, and/or selectable as a viewer request function from the CloudFront UI.
Client example
Time for the client side code. I'll use some Expo examples since the original solution here came to me while I was building an Expo app.
PUT image
The image upload request should largely be a simple HTTP PUT request with a few headers.
The notable things to take away are:
- You must include an
Authorization
header - If possible, always use a fresh token to avoid token expiration mid-flight.
- Craft your image path based on your application's needs, like if you intend to do additional verification between the URL and token claims
- Store the image path in your DB.
Here are a few libraries I like, that simplify working with images.
expo-file-system
provides an easy-to-useuploadAsync
method.expo-image-manipulator
provides a simple API for reducing image size. This trick is handy to keep in your back pocket since smaller image sizes directly reduce S3 egress fees, and of course load faster in your app.
GET image
When retrieving images, reference the previously created object urls that you stored in your primary database. And remember that the same requirement for authorization header applies.
expo-image
provides
a simple interface for including that, and provides some on-device caching options
to help cut down requests, if needed.
Thoughts
At the time that I originally implemented this, I thought it was pretty clever, and sufficiently secure for my app's requirements — signed in users could share and view each other photos. Nobody else could access them.
The authorization at edge is also not particularly novel as it is plain old RS256 JWT verification. The AWS CloudFront-Lambda-S3 stack could in theory be replaced by a fully Cloudflare setup, which would look like:
- Compute: Lambda@Edge -> Cloudflare Workers
- Storage: S3 -> R2
- CDN: CloudFront -> Cloudflare Workers + Cache
Again, if you're reading this an notice any security flaws or gaps, please reach out and let me know! Thanks for reading.
Footnotes
-
Facebook's CDN: https://scontent-lga3-2.xx.fbcdn.net/ ↩
-
S3's PutObject API: https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_Examples ↩
-
If you're curious to read a bit more about the why public S3 buckets are discouraged, check out this reddit thread: “What is the concern with granting S3 bucket public read access?” ↩
-
S3's Block Public Access: https://aws.amazon.com/s3/features/block-public-access/ ↩
-
"The IAM execution role associated with the Lambda function must allow the service principals
lambda.amazonaws.com
andedgelambda.amazonaws.com
to assume the role." - https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html#lambda-at-edge-restrictions-role-permissions ↩