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.

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:

    select id, public from storage.buckets where id = 'invoices';
    
    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:

    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:

    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:

    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.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us