Skip to main content
A Supabase signed URL stays valid until it expires, as long as an object still exists at its exact key. Signed URLs have no revocation list. To revoke every token issued for a key, copy the object to a new, unpredictable key, move every reference, and then delete the old key. The operator tool for this lives in scripts/storage-rekey/. It is framework-independent and has no dependencies. Use it only with per-root approval from the release authority. Do not rotate the storage JWT secret instead. Rotation invalidates every signed URL and session across the project, and it cannot be rolled back.

Preconditions

  • Storage RLS ownership (#6879) is live in production, so members cannot mint new long-lived tokens directly.
  • The release authority has given an explicit GO for each root.
  • The AI-chat filename compatibility change from the tool PR is deployed. It strips the <uuidv4>- re-key prefix from display names and from MarkItDown name lookup. Without it, re-keyed chats/ai/resources/ files show the UUID in the UI.
  • For .tuturuuu/, mobile deployment vault edits are frozen for the duration of the run (see inventory).
  • Run off-peak, from an operator host with the ledger directory at mode 700.

How the tool works

Each object moves through these states, recorded in a mode-600 JSON ledger: selected → copied → verified → refs_updated → old_deleted, with skipped, failed and rolled_back as exits.
  1. Select. This is a read-only metadata query on storage.objects for one root. Objects whose chat, conversation, attachment or owner has been deleted are recorded as skipped and listed. They are never copied.
  2. Copy. A server-side Storage copy to <dir>/<uuidv4>-<file> in the same directory, so prefix checks and listings still match. The new key is validated so it can never equal the old one.
  3. Verify. Compares the new object’s size and content type with the selected metadata. Bytes are never read.
  4. Update refs. One SQL statement per object, so it is one transaction. It holds a compare-and-swap on the old object’s id and updated_at, plus a row lock. If the object changed since selection, the tool deletes its copy and marks the object skipped (changed_since_selection). The same statement restores owner, owner_id and created_at on the copy, because service-role copies drop ownership and AI chat attachments are ordered by created_at.
  5. Delete old. This runs only after the refs commit. Afterwards the tool confirms through metadata that the old key is gone.
Safeguards:
  • Batches hold at most 50 objects.
  • Each operation is retried up to 3 times with exponential backoff on network, 429 and 5xx errors.
  • --max-ops caps the total Storage and SQL operations for a run (500 by default).
  • The tool checks a durable STOP file (default: <ledger>.STOP) before every operation.
  • A ledger lock file prevents two concurrent runs.
  • Logs carry only object ids, states and counts. They never include names or bytes.
Credentials:
  • SUPABASE_ACCESS_TOKEN_FILE must name a mode-600 file holding a Supabase Management API token.
  • SQL runs through the Management API query endpoint, and selection uses read_only.
  • The service-role key is fetched into memory and never persisted.

Reference inventory

Re-keying changes object keys, so each root must have every holder below updated in the same refs step.

chats/

  • private.chat_message_attachments.storage_path (relative, chats/<conversation>/<file>) and full_path (full key): updated.
  • public.ai_chat_messages.metadata: every whole JSON string equal to the old full key is rewritten, along with the ai-file:<encodeURIComponent(key)> attachment id. This covers toolResults[].output.storagePath, ai.parts[].output.storagePath, toolCalls[].input.storagePath and attachments[].storagePath/id. Rows are scoped by chat_id when the key names a chat.
  • chats/ai/resources/<chatId>/ and temp/<userId>/ have no key column. Readers list the folder, so the new same-directory key is found automatically. The restored created_at keeps attachment order.
  • Prefix checks in database functions (starts_with(path, 'chats/<conversation>/')) and in route guards still pass for same-directory keys.
  • Not rewritten:
    • Idempotency payload hashes for chat send replay and external delivery. An in-flight retry that still carries an old path is rejected as a payload mismatch.
    • The native AI mirror (<sha256>-<i>-<file>). It is re-derived on the next turn.

mira/images/

  • public.ai_chat_messages.metadata storagePath: rewritten. The key names no chat, so each object’s update scans ai_chat_messages. That is acceptable for the 3 objects in scope. Scope it by message before using the tool on a large Mira set.
  • private.ai_credit_reservations.metadata and public.ai_credit_transactions.metadata storagePath: rewritten on image_generation rows. These are not scoped by workspace, because their ws_id is the billing workspace, which can differ from the path. The chat sign-on-read check uses the transaction storagePath.
  • The stored imageUrl is a signed URL for the old key. The chat UI signs storagePath on read and falls back to imageUrl only when no path is available. That UI change must be deployed before the mira/images run.

.tuturuuu/

The root workspace’s mobile deployment vault has exactly one reference holder:
  • private.mobile_deployment_file_artifacts.storage_path (relative, storage_provider = 'supabase'). Draft inheritance copies artifact rows between versions with the same storage_path, so one object can have several rows. The single refs statement updates all of them together.
Side effect: the mobile_deployment_track_draft_write trigger bumps inheritance_revision on each affected version. It raises an error if that version’s inheritance_initialized is false. In that case the whole statement rolls back and the object is marked failed, with no partial update. Audit event metadata records kinds and sizes, never paths. The bundle fetch reads storage_path, so it follows the database. R2-provider rows are out of scope. .tuturuuu execution also requires --references-confirmed.

Held outside our database

These holders cannot be rewritten by the tool:
  • Mobile caches: restored chat attachments, offline upload receipts, the image cache key assistant-tool-image:<storagePath>, and composer drafts.
  • Short-lived (1-hour) signed URLs held in web and mobile client state.
  • Copies of the chat and vault helpers in apps/infrastructure.
Stale client caches recover after a refresh. AI providers receive inline bytes, not keys.

Production procedure

The examples set these variables:
Run the roots in the approved order. For each root:
  1. Dry run (read-only). This writes selected and skipped rows plus a mode-600 approval token file, <ledger>.<root slug>.approve-token, where the slug is chats, mira_images or _tuturuuu.
    Attach the printed per-state counts and the skipped ids to the approval record. Then stop and get the release authority’s GO.
  2. Execute. The token works once and is bound to the exact set of pending and failed rows. If that set changes, the token is rejected. The tool reads the token from its mode-600 file and never accepts it on the command line, so it stays out of process listings and shell history.
    Exit code 3 means the run stopped, either on the STOP file or the --max-ops cap. The ledger is consistent, so you can resume.
  3. Resume after a stop. Remove the STOP file, run $REKEY --issue-token --root <root> to write a fresh token file, then run --execute again. Rows already in the copied, verified or refs_updated state continue from where they stopped. A copy that landed before a crash is adopted, never duplicated.
  4. Retry failed rows. A failed row records the state it failed from. A failure at delete is the case that matters: refs are already on the new key, but the old key and its leaked URLs are still live. After the cause is fixed, run $REKEY --issue-token --root <root> and then:
    Each failed row resumes at its recorded state. A row that has failed 3 times stays failed and is listed as exhausted. Roll it back or investigate it.
  5. Report. Run $REKEY --status to print per-root counts for every state, plus the skipped and failed ids.

Stop and rollback

  • Stop: run touch $LEDGER_DIR/ledger.json.STOP. The job halts before its next Storage or SQL operation, and stays halted across restarts until you remove the file.
  • Rollback before old_deleted: this is lossless. Remove STOP, run $REKEY --issue-token --root <root>, then run $REKEY --rollback --root <root> --approve-token-file $LEDGER_DIR/ledger.json.<root>.approve-token. For every selected, copied, verified, refs_updated or failed row, the rollback reverts the references to the old key, deletes the copy, and marks the row rolled_back.
  • After old_deleted: the tool refuses to roll back. It reports the count in oldDeletedNotRolledBack. Copying the new key back to the old key revives every leaked token, so that is an incident decision for the release authority.
  • Keep the ledger, which holds the old-to-new mapping, on the operator host for the retention period. Never paste it into tickets or chat.

Revocation check and the CDN cache

  • Storage serves a signed URL only while an object exists at its key. After old_deleted, the tool confirms through metadata that no storage.objects row remains at the old key.
  • Supabase Smart CDN can briefly serve cached bytes after a delete. Before you claim revocation, HEAD a sample of known-old URLs taken from edge or API logs within their retention window, and confirm they return 400 or 404. In production the tool never mints signed URLs for private objects.
  • In staging, --probe-every N mints a 10-minute signed URL for every Nth object before deletion. After deletion it records the HEAD status, and keeps only the status in the ledger.

Staging rehearsal

scripts/storage-rekey/rehearsal.mjs creates synthetic fixtures in the seeded staging workspace, verifies them, and removes them. It refuses any project not named as development or staging. It covers:
  • AI chat files.
  • A native chat attachment, plus a deleted one.
  • A file from a deleted chat.
  • A Mira image.
  • A .tuturuuu object.
  • Two vault versions that share one artifact path.
The unit tests in scripts/storage-rekey/*.test.mjs cover:
  • Ledger transitions and terminal states.
  • The one-time, plan-bound approval token.
  • The stop fence and the op budget.
  • Restart adoption of a copy that landed before a crash.
  • CAS skips and deleted-chat skips.
  • Rollback, including the refusal after old_deleted.