# Private files still accessible after logout

> Why a private Storage file can still open after a user signs out (signed URLs, access tokens that haven't expired, browser cache) and how to shorten each window.

Source: https://supahost.app/kb/private-files-still-accessible · Updated 2026-11-11

Signing out doesn't recall URLs or tokens that were already handed out. A private file can stay reachable for a while after logout through a signed URL, an access token that hasn't expired, or the browser's cache. Each has a limited lifetime that you control.

## What you see

A user signs out, then pastes a file link into a new tab and it still opens. Or a shared link to a private invoice keeps working for someone who never signed in.

## Why it happens

1. **Signed URLs are bearer links.** `createSignedUrl(path, expiresIn)` returns a URL with a token in it. Anyone holding it can download the file until `expiresIn` runs out, signed in or not. Signing out doesn't change that.
2. **Access tokens live until they expire.** Storage and the Data API check a token's signature and expiry. They don't look up whether its session has since ended. `signOut()` ends the session and its refresh token, but a copied access token keeps working until its `exp`.
3. **The browser cached the file.** A response with `cache-control: max-age=3600` can be shown from cache for an hour without asking the server.
4. **The bucket isn't really private.** If `public` is true, the `/object/public/` URL works for everyone.
5. **Your own server fetched the file with `service_role`** and passed it on without checking who asked.

## How to fix it

1. **Confirm the bucket is private:**

   ```sql
   select id, public from storage.buckets where id = 'invoices';
   ```

   ```sql
   update storage.buckets set public = false where id = 'invoices';
   ```

2. **Use short signed URLs** and create them right when the user clicks, not when the page loads:

   ```js
   async function openInvoice(path) {
     const { data, error } = await supabase.storage.from('invoices').createSignedUrl(path, 60)
     if (error) throw error
     window.open(data.signedUrl, '_blank')
   }
   ```

   A 60-second link is enough to start a download and useless soon after.

3. **Sign out everywhere when it matters**, for example after a password change:

   ```js
   await supabase.auth.signOut({ scope: 'global' })
   ```

   This ends every session for the user, so no device can refresh its token. Tokens already issued still work until they expire.

4. **Shorten the access token lifetime** if an hour is too long for your app. Lower values mean more refreshes, so don't go lower than you need.

5. **Stop browsers keeping private files** by uploading them with a short cache time:

   ```js
   await supabase.storage.from('invoices').upload(path, pdf, { cacheControl: '0', contentType: 'application/pdf' })
   ```

6. **Check access in your server code** before streaming a file fetched with `service_role`.

## On SupaHost

The access token lifetime is set per project: *Dashboard → your project → **Auth** tab → Sign-in & security → Access token lifetime*, between 300 and 604,800 seconds (default 3600). Click **Save sign-in settings**; the change applies within about a minute, to tokens issued after that.

There's no way to revoke a single signed URL. If one leaks, move or rename the file (`supabase.storage.from('invoices').move(old, next)`), which makes the old URL fail.

## Related

- [Serving and downloading files](/docs/storage/serving-files)
- [Sessions, JWTs and refresh tokens](/docs/auth/sessions)
- [Files not caching (cache-control)](/kb/storage-cache-control)
- [Storage access control](/docs/storage/access-control)
