Why signed S3 URLs fail in Retool's PDF component, and the fix
reading · 00%

Why signed S3 URLs fail in Retool's PDF component, and the fix

A PDF that will not load in Retool, traced to how the S3 URL was signed.

Published
Reading5 min
CategoryRetool
Words1,107

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:

Plain textbrowser console
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

  1. Sign in to the AWS Console and open S3.

  2. Select the bucket your Retool app reads from.

  3. Open the Permissions tab and scroll to Cross-origin resource sharing (CORS).

  4. Select Edit.

Step 2: add the configuration

JSONs3-cors.json
[
  {
    "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:

Plain textS3 response header
Access-Control-Allow-Origin: https://yourapp.retool.com

The 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:

JSONs3-cors.json (excerpt)
"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.com admits 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.

TypeScriptpdf-proxy.ts
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.

Filed underRetool
Worth passing along? Share this note.
LinkedInX
Newsletter

Get the next one in your inbox

We write when something is worth reading. Subscribing means you are happy to hear from us when that happens. Leave any time. Privacy policy.

Arsany Milad

Keep readingRetool
Related notes

Still here? Good sign

More notes from real builds, written up for the next person who needs one.

All articles
ContactLet's chat
Let's chat

Tell us what
you're building

Describe the process that is slowing your team down: the inbox, the spreadsheet, the portal nobody owns. We'll come back with a concrete approach, a rough timeline, and, where it helps, a working prototype within weeks.

What happens next
  1. We read every messageAn architect reads what you sent before anyone replies.
  2. You hear back in ≤ 2 daysA short reply with first questions and a suggested next step.
  3. We scope and prototypeA scoping call, then a working surface, not a slide deck.
Where are you in the process?