A signed S3 URL opens the PDF in a new browser tab. Paste the same URL into Retool's PDF component and the frame stays blank. The network tab shows net::ERR_FAILED, and the app itself shows no error.
The signature is valid, and so is the URL. The bucket has not been told that your Retool domain may read from it across origins. The fix is a CORS rule on the bucket.
Why the browser tab works and the component does not
The two requests reach S3 in different ways.
Pasting a URL into the address bar is a top-level navigation. The browser does not apply CORS checks to navigation, so the S3 response comes straight back.
The PDF component loads the file with fetch(). A fetch from your Retool app to s3.amazonaws.com is a cross-origin request, and the browser enforces CORS on it. Depending on the headers involved, it may send a preflight OPTIONS request first. Either way, it checks the S3 response for an Access-Control-Allow-Origin header that matches your Retool domain. If the header is missing, the browser withholds the response and the component receives nothing.
The console says so plainly:
Access to fetch at 'https://your-bucket.s3.amazonaws.com/report.pdf?X-Amz-Signature=...'
from origin 'https://yourapp.retool.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Nothing is wrong with the signing code, the IAM role, or the bucket policy. The missing piece is one bucket setting.
The fix: a CORS rule on the bucket
You change the bucket's CORS configuration only. No IAM changes, no bucket policy edits, and no change to how you generate signed URLs.
Step 1: open the bucket's CORS settings
Sign in to the AWS Console and open S3.
Select the bucket your Retool app reads from.
Open the Permissions tab and scroll to Cross-origin resource sharing (CORS).
Select Edit.
Step 2: add the configuration
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": ["https://yourapp.retool.com"],
"ExposeHeaders": ["ETag", "Content-Type", "Content-Disposition"],
"MaxAgeSeconds": 3000
}
]Replace yourapp.retool.com with your own Retool domain. If your apps run on https://app.retool.com, use that.
What each field does:
AllowedOrigins: the domains allowed to make cross-origin requests to the bucket.
AllowedMethods: the HTTP methods those origins may use.
AllowedHeaders: the request headers the browser may send, including the ones Retool adds to its fetch.
ExposeHeaders: the response headers the browser may read, such as content type, disposition, and ETag.
MaxAgeSeconds: how long the browser caches the preflight result. 3000 seconds is 50 minutes.
Step 3: save and verify
Save the configuration. In Retool, hard-refresh the page with Ctrl+Shift+R (Cmd+Shift+R on a Mac), not a normal refresh. Open DevTools, select the Network tab, and reload the PDF component.
The S3 response should now carry the header:
Access-Control-Allow-Origin: https://yourapp.retool.comThe PDF renders. If it does not, the likeliest cause is a response the browser cached before the change.
Production and staging on separate domains
List each Retool domain explicitly:
"AllowedOrigins": [
"https://yourapp.retool.com",
"https://yourapp-staging.retool.com"
]Keep the list as short as it can be. A wildcard ("*") on a private bucket allows cross-origin reads from any website. *.retool.com covers every Retool-hosted app, across every Retool customer, not only yours. On a bucket that serves private documents, name your origins one by one.
When to add PUT or POST
For rendering PDFs and images, GET and HEAD are enough.
Add PUT only if the browser uploads files straight to S3, for example with a presigned upload URL created for the frontend. If uploads go through your backend or a Retool resource query, the browser never sends the upload request itself. Each method you add widens what cross-origin requests can do on the bucket, so list only the ones you use.
Why the fix sometimes seems not to work
Five mistakes account for most cases where the blank frame survives the fix:
Debugging the signature. The URL works in a tab, so the signature is fine. CORS is a separate check, applied only to the fetch.
Allowing any origin. A
"*"origin removes the origin restriction entirely, which is wider than any app needs on a private bucket.Allowing every Retool subdomain.
*.retool.comadmits every Retool customer's apps. Use your own domain.Skipping the hard refresh. The browser may still act on a cached response from before the change.
Editing the wrong bucket. If production and staging read from different buckets, confirm which one the app uses before changing anything.
If the hard refresh does not help, check the Network tab again. If Access-Control-Allow-Origin is still missing from the S3 response, the rule was saved on a different bucket.
Other components with the same symptom
Any Retool component that loads a file in the browser with fetch() can hit the same wall. The PDF component is the common case, because PDFs usually come from private buckets through signed URLs. The tell is always the same: the URL opens in a tab and the component shows nothing. The fix is the same bucket rule. It applies to a viewer you build yourself as well, such as a custom document viewer component.
If you cannot change the bucket
Sometimes another team owns the bucket and the CORS change waits on a ticket. The workaround is to fetch the file on the server and hand Retool a data URI.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function pdfAsDataUri(bucket: string, key: string): Promise<string> {
const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (object.Body === undefined) throw new Error(`Empty object: ${key}`);
const bytes = await object.Body.transformToByteArray();
return `data:application/pdf;base64,${Buffer.from(bytes).toString("base64")}`;
}Return the string from an endpoint that a Retool resource query calls, and bind the component's source to the query result. The request to S3 now starts on a server, and CORS does not apply to server-to-server requests. A Retool resource query to S3 runs server-side for the same reason.
The cost grows with the file. Base64 adds about a third to the payload, and the whole document passes through memory on every view. For an occasional small PDF that is acceptable. For large files or frequent views, the bucket rule is the better path.
Our view
We recommend the bucket CORS rule with explicit origins, because it fixes the cause in one place and keeps the signed URL doing its job. The server-side proxy is a stopgap for the week the bucket is out of reach. Keep it until the CORS change lands, then remove it. For the wider list of what a private bucket and its apps should pass before go-live, see the security and compliance checklist.