7 of 21 third-party apps I audited in June and July 2026 had a confirmed flaw where a logged-in user could read or write another customer’s data. A file bucket is where I would expect that to go unnoticed: the app’s screens look the same whether a bucket is public or private. An object storage security checklist covers it in five lines.

The object storage security checklist: five lines, and where each is set

An object storage security checklist for a small app has five lines: the bucket is private, nobody but the owner can list it, private files leave only through signed links that expire, uploads are capped by size and type where they land, and every upload has a retention rule and a deletion path.

A word on that opening number. The 21 apps were the public and held-out third-party apps I audited: 11 audited exhaustively across all 12 pillars, and 10 more held out and audited blind. They were selected, not drawn at random, so 7 of 21 describes that set and is not a rate for AI-built apps in general.

Files are one area among the checks in the data consistency checklist for SaaS. The table below is the file part in full, one row per line, with the setting that enforces it on each of the three products this page covers. It is the whole template; there is nothing to download.

Checklist lineWhat it stopsSupabase StorageAmazon S3Firebase Cloud Storage
1. The bucket is privateAnyone with a file’s URL reading itNew buckets are private by default; the public flag can be changed later with updateBucket or “Make public” in the dashboardBlock Public Access at account and bucket; new buckets don’t allow public access by defaultSecurity Rules on each path; the example default rules require Firebase Authentication for any read or write
2. Nobody outside the owner can list itOne leaked path becoming the whole bucketListing runs through RLS policies on storage.objectss3:ListBucket granted to nobody outside the accountA separate list rule (Security Rules version 2)
3. Private files go out only through expiring signed linksA shared link that works forevercreateSignedUrl(path, expiresIn), expiry in secondsPresigned URL: 1 minute to 12 hours from the console, up to 7 days from the CLI or SDKsgetDownloadURL(); how long its URL lasts is not stated in Firebase’s docs
4. Uploads capped by size and type where they landOversized or wrong-type files that skip the app’s checksBucket fileSizeLimit and allowedMimeTypes, under the project’s global limitContent type fixed when a PUT URL is signed; content-length-range in a presigned POST policyRule conditions on request.resource.size and request.resource.contentType
5. Each upload has a retention rule and a way to delete itFiles that outlive the account or the reason they were uploadedA scheduled job; the bucket lifecycle call supports only noncurrent-version expirationLifecycle expiration rules by prefix, plus aborting incomplete multipart uploadsGoogle Cloud Storage lifecycle rules, since the files sit in a Google Cloud Storage bucket

Docs checked 2026-09-30: Supabase’s storage bucket guide and its reference pages, the AWS S3 user guide, and Firebase’s Cloud Storage security pages. To review storage access rules before launch, work the table from the first row to the last, then run the six tests at the end of this page.

Builder-hosted storage sits on the same products. Lovable Cloud, the built-in backend whose list includes storage, is built on Supabase’s open-source foundation. Replit App Storage, formerly Object Storage, is built-in object storage on Google Cloud Storage, with bucket access policies and per-app access control. My reading is that the same five lines apply to both, set through the product underneath.

File storage is deliverable 4.14 in the Production Hardening Sprint, and the published scope gives the reason in one sentence: file buckets are the most common place an AI-built app is publicly readable without anyone noticing.

What is a presigned URL, and how long should it live

A presigned URL is an ordinary link carrying a signature and an expiry time, created by code that holds the storage credentials. Whoever holds it can download or upload that one file with no login until it expires, so the expiry is the control. My working rule: minutes for a download from a logged-in page, longer only for emailed links.

“Presigned URL” is AWS’s word; Supabase and Google Cloud say signed URL, and Google Cloud’s page describes the signature as authentication information carried in the link’s query string. Anyone with valid credentials can create one, and the person using it needs no AWS security credentials or permissions of their own. A signed download URL is the same thing pointed at a download. A presigned upload URL lets someone upload a specific object straight into the bucket, and if an object with that key already exists, S3 replaces it. The details are in AWS’s guide to presigned URLs.

The vendor docs give a ceiling, not a recommendation. On S3 the console allows 1 minute to 12 hours and the CLI or SDKs allow up to 7 days, and a link signed with temporary credentials can’t outlive those credentials. Supabase’s createSignedUrl takes the expiry as a number of seconds, and a maximum is not stated on its reference page. So the choice is yours, and this is how I would make it:

File kindSuggested expiryWhy
Inline image on a logged-in pageMinutes, re-issued on each page loadThe page asks for a fresh link every time, so a long one buys nothing
One-off download, such as an invoiceMinutesThe user clicks it straight away
Link sent in an emailHours to a few daysEmail is read later; the link is a bearer link, so it should open nothing the recipient could not forward anyway
Upload URLMinutes, for one object keyThe browser uses it once, right after your server issues it

A leaked link works for whoever has it. AWS calls presigned URLs “bearer tokens that grant access to those who possess them”, usable multiple times up to the expiration date and time, and its page says that, in general, a presigned URL expires when the credential used to create it is revoked, deleted, or deactivated. In my reading that makes a single S3 link hard to cancel on its own: pulling the credential ends every link it signed. The same AWS page shows a bucket policy using s3:signatureAge to deny presigned requests whose signature is more than 10 minutes old, which caps every link in the bucket at once. Supabase’s signed URLs “remain valid until their expiry time regardless of any Auth key changes”, and its page says to contact Supabase support to revoke them.

The Microsoft Security Response Center’s post of September 18, 2023 describes what an over-generous signed link costs. A Microsoft employee shared a URL for a blob store in a public GitHub repository while contributing to open-source AI learning models, and the URL included an overly-permissive Shared Access Signature (SAS) token for an internal storage account; security researchers then used the token to access information in the storage account, including backups of two former employees’ workstation profiles and internal Microsoft Teams messages. Microsoft says no customer data was exposed and that there was no security issue or vulnerability within Azure Storage or the SAS token feature; the issue was reported on June 22, 2023, and MSRC revoked the token and prevented all external access to the storage account, mitigating it on June 24, 2023. Microsoft then expanded the SAS detection that GitHub’s secret scanning runs to include any SAS token that may have overly-permissive expirations or privileges, and says its own historical rescans had detected the URL but the finding was incorrectly marked as a false positive.

The lesson I take from it: a signed link is a credential, so its scope and its expiry decide what a leak costs. The post’s own advice is a near-term expiration, and it says “Azure Storage recommends 1 hour or less for all SAS URLs.” Issuing a link only after an authorization check is a step of its own, covered in file upload testing and the controls behind it.

What goes wrong without it

Storage bucket files that are publicly readable, a listing anyone can run, a default that was changed, and a policy that checks the wrong thing: each one fails without an error on screen. The table sets out how a stranger would come across each, what it gives away, and the checklist line that closes it.

What is wrongHow a stranger finds outWhat is exposedChecklist line
Storage bucket files publicly readable: a bucket made public so avatars would load later holds invoicesA URL leaks through a referrer header, a shared screenshot or a guessable path with a user id in itEvery file whose URL they have, with no loginThe bucket is private
Anyone can list files in the bucket: list access granted to anonymous callers or to every signed-in userOne list requestEvery file name, which turns one leaked path into all of themNobody outside the owner can list it
Bucket policy left public after a change: someone flipped the bucket or the policy to public to fix a broken imageAny of the routes in the first rowWhatever the new policy grantsThe bucket is private
Private bucket, but any signed-in user can read any path: the policy checks that the caller is signed in, not that they own the pathA free account, then a request for another user’s pathOther customers’ filesPrivate bucket and no listing, with policies tied to the owner’s path

The leak routes in that table are my examples, and what a listing exposes is my reading of what a list permission returns. A bucket policy left public by default is not what the three vendors ship today. AWS’s Block Public Access page says “By default, new buckets, access points, and objects don’t allow public access”, and new buckets also get Object Ownership set to Bucket owner enforced with all ACLs disabled. Supabase’s bucket guide says “Buckets are private by default”, and Firebase’s security page shows default rules that require Firebase Authentication for any read or write. So in my reading, a public bucket on these products starts with a choice: a person or an AI builder switching on public to make a broken image load. No source I read counts how often that happens.

The fourth row is, in my reading, the storage form of the cross-user failure in the opening number. In the same audits, the Authorization pillar averages 42.1 out of 100 across the 14 third-party apps scored on it. Those 14 are the apps from the same public and held-out group where the pillar applied; the rest were left out rather than scored as zero, and the group was selected, not sampled, so the average describes those apps only.

For a Supabase project, the two-minute test of whether someone can access your Supabase database is a quick outside check. Why a public Supabase bucket skips its policies when files are read is in is Supabase safe: the platform answer and the app answer. For Lovable projects, the Lovable data breach and whether your app was exposed covers what was visible and how to check. None of this says your bucket is open; the six tests further down decide that.

How to do it on Supabase Storage, Amazon S3 and Firebase Cloud Storage

The five lines are the same on every product; the knobs differ. Two products this page does not cover in full use the same pattern: Cloudflare R2 presigned URLs follow the S3 form, with an expiry from 1 second to 7 days (604,800 seconds), and Google Cloud Storage signed URLs go up to 604800 seconds (7 days).

Supabase Storage: private buckets, policies on storage.objects, and bucket limits

New Supabase buckets are private unless you pass public: true, and the flag can be switched later through updateBucket. Access to a private bucket is controlled by RLS policies created on the storage.objects table, with file paths parsed by helpers such as (storage.foldername(name))[1]. By default, Supabase Storage does not allow any uploads to buckets without RLS policies.

  1. 01 Keep the bucket private. Create it without public: true, and check the flag in the dashboard for every bucket that holds user files.
  2. 02 Give each user or tenant a folder, and have the policy compare the first folder in the path with the caller's id, the pattern Supabase's access control guide uses for per-user uploads.
  3. 03 Write the SELECT, INSERT, UPDATE and DELETE policies on storage.objects for that folder rule, and nothing broader.
  4. 04 Set the bucket's own fileSizeLimit and allowedMimeTypes, below the project's global file size limit.
  5. 05 Issue downloads from a server route with createSignedUrl and a short expiresIn, after checking the caller may read the file.

The folder example is in Supabase’s storage access control guide, and the bucket options are shown in creating buckets in Supabase. The policy SQL itself, and the trap where an upload fails for want of a SELECT policy, are in the storage policy section of Supabase RLS best practices. Before you count on backups, note that Supabase database backups exclude Storage API objects. The fix is in how to back up Supabase Storage buckets, and backups in general are in the database backup checklist for startups.

S3 security: Block Public Access, the bucket policy, AWS Config S3 rules and the policy validator

S3 security for an app bucket comes down to four things: Block Public Access on the account and the bucket, a bucket policy that grants nothing to everyone, ACLs disabled through Object Ownership, and nothing public in front of it. AWS Config rules flag public read or write, and IAM Access Analyzer checks a policy before you save it.

The first three are AWS settings; the fourth, any access point or CDN path in front of the bucket, is my addition to the list. S3 Block Public Access has four settings, each usable on an access point, a bucket or the whole account, and S3 applies the most restrictive combination; AWS recommends applying BlockPublicPolicy at the account level.

Setting or toolWhat it doesWhere in the consoleWhat a pass looks like
Block Public Access: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBucketsOverrides policies and ACLs that would allow public accessBucket, then Permissions, then Edit next to Block public access (bucket settings)All four on, for the bucket and the account
Bucket policyGrants access to the bucket and its objectsPermissions tab, then Edit under Bucket policyNo statement grants s3:GetObject or s3:ListBucket to everyone
Object OwnershipBucket owner enforced (the default) disables all ACLsNot stated on AWS’s Object Ownership pageBucket owner enforced
AWS Config s3-bucket-public-read-prohibited and s3-bucket-public-write-prohibitedCheck Block Public Access, the bucket policy and the bucket ACL; triggered by configuration changes and periodicallyAWS Config, as managed rules; the console path is not stated on the rule pagesCompliant on every bucket
IAM Access Analyzer policy validationReturns security warnings, errors, general warnings and suggestions in the bucket policy editorEdit bucket policy pageNothing left to resolve before you save
IAM Access Analyzer for S3Findings for each bucket shared publicly or with other AWS accountsAccess analyzer for S3, under DashboardsNo bucket under Buckets with public access

AWS Config for S3 can mean three different things: AWS Config’s managed rules that watch S3 buckets, the S3 bucket set up for AWS Config’s delivery channel, or the AWS CLI’s own configuration for S3 transfers, which has nothing to do with bucket safety. The rules are the ones that matter here, and AWS Config’s public-read rule for S3 checks the Block Public Access settings, the bucket policy and the bucket ACL. The write rule does not evaluate changes to account-level Block Public Access. AWS Config is paid: per AWS Config pricing, you are charged based on the number of configuration items recorded, the number of active AWS Config rule evaluations and the number of conformance pack evaluations.

The S3 policy validator is IAM Access Analyzer policy validation, which checks a policy against IAM policy grammar and AWS best practices; the S3 guide says to resolve its findings before you save a bucket policy. IAM Access Analyzer for S3 “requires an account-level analyzer in each AWS Region where you have buckets” and is “available at no extra cost on the Amazon S3 console”. Listing a bucket’s keys needs the s3:ListBucket permission, which the bucket owner has by default and can grant to others; the pass line is that it is never granted to everyone.

Firebase Cloud Storage: rules in one paragraph

Firebase Security Rules are defined outside the app in the Firebase console or CLI and cover Cloud Firestore, Realtime Database, and Cloud Storage. The rule that matters matches the path to request.auth.uid, splits reads into get and list so listing can be refused on its own, and adds request.resource.size and request.resource.contentType conditions for the limits. The example default rules on Firebase’s Cloud Storage security rules page require Firebase Authentication for any read or write, which still lets any signed-in user read any file the rule matches. The full rule is printed in the owner-scoped Cloud Storage rule, and the wider picture is in is Firebase secure: what Google protects and what you configure.

Files in the database: why the PostgreSQL binary data type is not object storage

PostgreSQL’s binary data type is bytea, a variable-length binary string stored in a table column. It suits small values such as hashes and keys. It is not object storage for user uploads: every file passes through the API server and the database, the database and its backups grow with every upload, and there is no signed link.

bytea accepts two formats for input and output, hex and PostgreSQL’s historical escape format, with hex as the default output, per PostgreSQL’s binary data types page. Builders reach for the Postgres binary data type because it needs no second service: the upload goes into the same insert as the row. The documented maximum field size is 1 GB.

Questionbytea columnObject storage
Largest single file1 GB per fieldSet by the bucket or the provider’s limits
Path of every downloadThrough your API server and a database connectionStraight from the bucket, or a CDN in front of it
Effect on backupsEvery upload makes each database backup biggerBacked up separately, on its own schedule
Time-limited link for one fileNoneSigned or presigned URL
Who can read a fileOnly the table’s row-level security and your APIBucket settings, policies and signed links

Only the 1 GB limit in that table comes from the PostgreSQL docs; the other rows are my reading. My working rule: if a user picks the file, it goes in a bucket, and the row holds the path, the size, the type and the owner.

To get out of an existing bytea table, I would work in this order: copy the files to the bucket in batches, write each path back to its row, check counts and checksums against the column, and drop the column in a later migration once reads come from the bucket. The database settings side of that change is in how to harden managed database settings. PostgreSQL also has a large object facility, which gives stream-style access to data kept in a special large-object structure; it is still inside the database.

Size and type limits where uploads land, where scanning sits, and the retention rule

Limits where uploads land matter because a presigned upload skips your server: the size cap and allowed types belong on the bucket or in the upload policy as well as in code. Retention, as my working rule, comes down to three rules: expire the temporary prefixes, delete files with their owner, and sweep files whose rows are gone.

The upload route’s own checks belong to file upload testing. The second place is the storage side, because a presigned URL lets someone upload an object without AWS security credentials, and Cloudflare describes presigned URLs as a way of allowing users to upload files directly to R2. In my reading, the bytes of a direct upload never pass through your server, so a size check there cannot cap them.

On S3, a PUT URL can be signed for a content type, and the upload must then send the same one. A size cap needs a presigned POST instead: the boto3 reference for generate_presigned_post lists content-length-range among the policy conditions, with the example ["content-length-range", 2, 5]. AWS’s page on uploading with a presigned URL shows the PUT form. On Supabase, the bucket’s fileSizeLimit and allowedMimeTypes apply, and a bucket’s limit can’t be higher than the project’s global limit, which can’t exceed 50 MB on Free projects. On Firebase, Security Rules checks on the incoming file’s size and content type do the same job. Whether a type option inspects the file’s bytes is not stated in Supabase’s or Firebase’s docs, so in my reading a renamed file that declares an allowed type passes it, and only a content check in the upload route or after landing stops it.

Where scanning sits, as my working rule: uploads land in a holding prefix, a scan marks each file, and only marked files move to the prefix your app serves. The scanning pipeline itself is covered with file upload testing. In practice the three retention rules become four items, with one extra step on S3:

  1. 01 Expire the holding and temporary prefixes after about a day to a week: an S3 lifecycle expiration rule for the prefix, a Google Cloud Storage lifecycle Delete rule for a Firebase bucket, and a scheduled job on Supabase.
  2. 02 On S3, add a lifecycle rule that aborts incomplete multipart uploads, so half-finished uploads do not linger.
  3. 03 Delete a user's files when the row or the account that owns them is deleted.
  4. 04 Sweep for files whose rows are gone, and keep the schedule written down.

S3 lifecycle rules delete expired objects on your behalf. Firebase gets the same through Google Cloud’s Object Lifecycle Management because “Cloud Storage for Firebase stores your files in a Google Cloud Storage bucket”. On Supabase, the lifecycle call only clears older versions of objects and turns away prefix filters, so a scheduled job does the deleting there. The delete path belongs with an account deletion feature that removes stored files, the orphan sweep with the database cleanup checklist, and the written schedule with a data retention policy for a small SaaS. Keep timestamps in paths and lifecycle rules in UTC, as in the time zone handling checklist.

How to verify it

Object storage is verified with 6 requests against your own bucket: a private file without a signature, an anonymous listing, an expired link, another user’s path, an oversized upload and a disallowed type. Each must be refused (an anonymous listing may come back empty instead). Keep the requests, the responses and the bucket’s settings with the date.

Run these against a bucket you own, never anyone else’s. Start in the browser: paste a private file’s plain URL into a private window. Then repeat it from a terminal, where you can see the status line. On Supabase, a file in a private bucket is not reachable through the public URL form, so this request should be refused:

curl -i "https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[asset-name]"

The exact status code for these refusals is not stated in Supabase’s or AWS’s docs, so the pass line below is “refused (no file body returned)”; write down the code your product sends.

  1. 01 Fetch a private file's plain URL with no signature. Pass: refused (no file body returned). Fail: the file.
  2. 02 Request the bucket's listing with no credentials. Pass: refused, or on Supabase an empty list, because a policy that filters rows out raises no error and matches zero rows. Fail: any file name.
  3. 03 Open a signed link after its expiry time. Pass: refused. Fail: the file.
  4. 04 Signed in as user B, with B's own session, request user A's path and a signed link for it. Pass: refused by the policy, not only hidden in the app. Fail: A's file.
  5. 05 Upload a file over the size limit, straight to the bucket if your app issues presigned uploads. Pass: rejected, or the object deleted once the after-landing check runs (list the bucket to confirm). Fail: the file stays.
  6. 06 Upload a disallowed type with a renamed extension through the app's own upload path. Pass: rejected by the bucket or by the route's content check. Fail: the file is stored and served.

Keep the six requests and responses with the date, a copy of the bucket’s settings page or policy text, and the lifecycle rule. Re-run them after any change to storage policies or the upload route.

In the sprint, we verify deliverable 4.14 the same way the scope states it: fetch a private file without a signed link and confirm refusal; upload an oversized and a disallowed file and confirm rejection; upload a safe malware-scanner test file and confirm rejection; and verify that expired uploads are removed according to the configured retention rule.

For an automated version of test 4 on Supabase, see how to test Supabase RLS, storage buckets included. These tests overlap two of the upload tests in file upload testing on purpose: that page tests the upload route, this one tests the bucket. Encryption of the bucket at rest is a separate question, covered in database encryption and what it covers.

Where the sprint does this

In the Production Hardening Sprint, deliverable 4.14 uses signed, expiring links for private files, enforces size and type limits, scans user uploads for malware, and sets a retention rule for uploads; how it is verified is quoted in the section above. Deliverable 3.2 validates upload types and sizes, enforces bucket permissions, and protects access to stored files. The result for both goes into the production readiness report, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from these engineering deliverables. Hosting, paid tools and API usage remain in your accounts, and we explain any required third-party costs before enabling them. The exact wording is in the file storage check in the published scope.

What is the difference between a signed URL and a presigned URL?

They are the same idea under two vendors’ names: AWS says presigned URL, while Google Cloud and Supabase say signed URL. Both are links carrying a signature and an expiry, and whoever holds one can use it without logging in until it expires.

Are S3 buckets encrypted by default?

Yes. Starting January 5, 2023, all new object uploads to Amazon S3 are automatically encrypted at no additional cost, with SSE-S3 as the base level of encryption for every bucket, per S3’s default encryption FAQ. In my reading, encryption at rest does not change who the bucket lets read a file, so a public bucket stays public.

How do I get an S3 presigned URL to upload a file?

Your server creates it for one object key, with the content type fixed at signing, and the browser sends the file with a PUT that uses the same content type. If you need a size cap, sign a presigned POST instead, with a content-length-range condition in its policy.

What is a list bucket?

It is the s3:ListBucket permission, which a caller needs to list the objects in an S3 bucket; the bucket owner has it by default and can grant it to others. My rule is never to grant it to everyone.

What is the Microsoft equivalent of S3?

Azure Blob Storage, and its form of the signed link is the shared access signature, which Microsoft Learn describes as providing “secure delegated access to resources in your storage account.” Microsoft’s page on shared access signatures in Azure Storage covers the types. In my reading, the same five lines apply there.