Export files to Supabase with cURL
To export a local file to private Supabase Storage, send its bytes with an authenticated POST, then download the object and compare it with the original. This walkthrough uses two ordinary Auth users: one uploads and reads the file, and the other checks that the access policy denies the same download.
Set up a private bucket and its policies
You need a Supabase project you administer and two distinct email/password Auth users who can already sign in. Complete email confirmation when your project requires it; see password-based Auth. Creating a Supabase account alone does not create these application users.
Create a new private Storage bucket named curl-exports, following the
bucket quickstart. Allow
application/octet-stream if you restrict MIME types, and choose a file that fits its size limit.
Use a fresh bucket for this example. Review existing storage.objects policies too: a permissive
rule that applies to this bucket can also grant access. The second-user check below tests the
effective read permissions.
Run this SQL once as the project administrator. It grants uploads and reads only inside a folder named after the signed-in user’s UUID:
create policy "curl_exports_insert"
on storage.objects for insert to authenticated
with check (
bucket_id = 'curl-exports'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "curl_exports_select"
on storage.objects for select to authenticated
using (
bucket_id = 'curl-exports'
and (storage.foldername(name))[1] = (select auth.uid())::text
and owner_id = (select auth.uid())::text
);
Storage access control
explains the storage.objects policies. The uploader below uses a user’s access token; a secret
or service-role key would bypass RLS and invalidate the privacy check.
Check your shell tools and credentials
Use Bash on Linux with cURL, jq,
and the usual cmp, mktemp, and rm utilities already installed. The tested versions are
Bash 5.3.15, cURL 8.22.0, and jq 1.8.2. These are tested versions, not exact version pins;
the script uses cURL’s --fail-with-body option and checks each required tool before creating
temporary files. Its requests ignore .curlrc settings. Run the setup commands in Bash.
curl --disable --version && jq --version && bash --version
This walkthrough uses an existing legacy anon API key, matching the tested local stack.
Supabase plans to deprecate legacy keys by the end of 2026; the
API key guide covers retrieval and
migration. Set that stack’s project URL, key, and bucket in the shell from which you will run
the script:
export SUPABASE_URL='https://YOUR_PROJECT_ID.supabase.co' \
SUPABASE_KEY='YOUR_ANON_KEY' \
SUPABASE_BUCKET='curl-exports'
Have your credential manager or job runner supply SUPABASE_EMAIL, SUPABASE_PASSWORD,
SUPABASE_OTHER_EMAIL, and SUPABASE_OTHER_PASSWORD as environment variables. The first pair
belongs to the uploader; the second belongs to the user who must be denied. Avoid putting passwords
in shell history. The project key does not replace the user JWT in the Authorization header.
This example was exercised against local Supabase Auth v2.196.0 and Storage v1.74.0 with PostgreSQL 17.6. Hosted project creation, TLS, dashboard configuration, and plan quotas are separate prerequisites. Use HTTPS for a hosted project; loopback HTTP is suitable for a local development stack.
Upload, download, and verify one file
Save the following as a new file named export-to-supabase.sh. Run it with Bash, rather than
sourcing it into your current shell. Its first argument is the local file; its second is the object
path beneath the uploader’s UUID. Object path segments must start with an ASCII letter or digit
and contain only ASCII letters, digits, spaces, dots, underscores, and hyphens. Local filenames
need not match this narrower object naming rule.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
die() { printf '%s\n' "$1" >&2; exit 1; }
for tool in curl jq cmp mktemp rm; do
command -v "$tool" >/dev/null || die "Missing required tool: $tool"
done
[[ $# -eq 2 ]] || die 'Usage: bash export-to-supabase.sh LOCAL_FILE OBJECT_PATH'
: "${SUPABASE_URL:?Set SUPABASE_URL}" "${SUPABASE_KEY:?Set SUPABASE_KEY}"
: "${SUPABASE_BUCKET:?Set SUPABASE_BUCKET}"
: "${SUPABASE_EMAIL:?Set SUPABASE_EMAIL}" "${SUPABASE_PASSWORD:?Set SUPABASE_PASSWORD}"
: "${SUPABASE_OTHER_EMAIL:?Set SUPABASE_OTHER_EMAIL}"
: "${SUPABASE_OTHER_PASSWORD:?Set SUPABASE_OTHER_PASSWORD}"
file=$1
case "$file" in /*) ;; *) file="./$file" ;; esac
[[ -f "$file" && -r "$file" ]] || die 'Select a readable regular file'
object_path=$2
path_pattern='^[A-Za-z0-9][A-Za-z0-9._ -]*(/[A-Za-z0-9][A-Za-z0-9._ -]*)*$'
[[ "$object_path" =~ $path_pattern ]] || die 'Unsupported object path'
[[ "$SUPABASE_BUCKET" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'Unsupported bucket name'
base_url=${SUPABASE_URL%/}
umask 077
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
sign_in() {
AUTH_EMAIL=$1 AUTH_PASSWORD=$2 jq -n \
'{email: env.AUTH_EMAIL, password: env.AUTH_PASSWORD}' |
curl --disable --fail-with-body --silent --show-error \
"$base_url/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_KEY" -H 'Content-Type: application/json' \
--data-binary @- --output "$3" || die 'Password sign-in failed'
jq -e '.user.role == "authenticated"' "$3" >/dev/null || die 'Expected an authenticated Auth user'
}
sign_in "$SUPABASE_EMAIL" "$SUPABASE_PASSWORD" "$work/owner.json"
sign_in "$SUPABASE_OTHER_EMAIL" "$SUPABASE_OTHER_PASSWORD" "$work/other.json"
token=$(jq -er '.access_token | select(type == "string" and length > 0)' "$work/owner.json")
other_token=$(jq -er '.access_token | select(type == "string" and length > 0)' "$work/other.json")
user_id=$(jq -er '.user.id | select(type == "string" and length > 0)' "$work/owner.json")
other_id=$(jq -er '.user.id | select(type == "string" and length > 0)' "$work/other.json")
[[ "$user_id" != "$other_id" ]] || die 'Use two distinct Auth users'
encoded_path=$(jq -rn --arg path "$user_id/$object_path" \
'$path | split("/") | map(@uri) | join("/")')
object_url="$base_url/storage/v1/object/$SUPABASE_BUCKET/$encoded_path"
download_url="$base_url/storage/v1/object/authenticated/$SUPABASE_BUCKET/$encoded_path"
curl --disable --fail-with-body --silent --show-error --request POST "$object_url" \
-H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $token" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@$file" --output "$work/upload.json" || die 'File upload failed'
curl --disable --fail-with-body --silent --show-error "$download_url" \
-H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $token" \
--output "$work/download.bin" || die 'Owner download failed'
cmp -s -- "$file" "$work/download.bin" || die 'Downloaded bytes differ from the selected file'
denied_http=$(curl --disable --silent --show-error "$download_url" \
-H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $other_token" \
--output "$work/denied.json" --write-out '%{http_code}') || die 'Access-check request failed'
case "$denied_http" in
400)
jq -e '(.statusCode | tostring) == "403" or (.statusCode | tostring) == "404"' \
"$work/denied.json" >/dev/null || die 'Unexpected Storage error'
;;
403|404) ;;
*) die "Expected access denial; received HTTP $denied_http" ;;
esac
printf 'Verified upload and private access.\n'
Replace ./report.bin with your selected file. Keep it unchanged while the script runs. The
remote filename can differ from its local name:
bash export-to-supabase.sh './report.bin' 'exports/report.bin'
A successful run prints:
Verified upload and private access.
The object remains at curl-exports/<uploader-uuid>/exports/report.bin. The downloaded copy and
Auth responses are temporary and removed when the script exits. A mismatch or an unexpected
successful download by the other user exits nonzero without printing this success message.
The private download endpoint
uses the user JWT and the read policy; the API key alone does not grant that access.
Handle existing objects without losing earlier exports
The POST creates a new object. Rerunning the same command targets the same UUID and object path, so an existing object is rejected. The earlier object remains stored. Use another object path for a new export.
Supabase’s standard upload guide
documents intentional replacement with x-upsert: true, which also needs SELECT and UPDATE
policies. This walkthrough grants no update permission and does not overwrite. If a connection
fails after the server stored the file, the upload’s outcome is uncertain. Inspect and download
that exact object before choosing whether to keep it or use a new path; a duplicate response on
retry can mean the first upload succeeded.
Understand file size limits
Choose a small file within both the project’s global limit and the bucket’s limit. Hosted plan
ceilings and bucket restrictions are described in
Supabase’s file limit documentation.
cURL’s --data-binary @file buffers the input in memory,
and this example does not resume an interrupted transfer. For larger files, follow the separate
resumable upload guide.
Automate file export in a daily workflow
The saved script signs in on every invocation, so automation does not depend on a pasted JWT remaining valid. Have your scheduler supply the environment variables, use absolute paths to the script and input, and choose a new object path for each export. Keep the second account as a permission check, and treat a nonzero exit as a failed verification even when the upload already exists remotely.
Troubleshoot common issues
Authentication errors (401 unauthorized)
Check the project URL and API key first. For a failed password login, check the user’s email, password, and confirmation status. The script stops at a failed login; it does not substitute an administrator key. If the token expires during a transfer, rerun with a fresh object path after checking whether the earlier upload committed.
Duplicate file errors
A duplicate path is an error, commonly HTTP 400 in Storage. Change the second script argument for a new export. Do not add automatic retries or upsert merely to conceal the collision.
File size limits (413 payload too large)
An oversized upload can produce HTTP 413. The tested Storage version returned HTTP 400 with body
status 413 for a bucket-size refusal. Check both size limits, and check the bucket’s MIME
allowlist for a type-related refusal. This script sends application/octet-stream; it does not
detect the file’s media type. See the Storage error codes
for the service’s diagnoses.
Permission denied
Check that the bucket is private and that the policies apply to its exact name, the uploader’s
UUID folder, and owner_id. An inaccessible private object can look missing: the tested Storage
version returned HTTP 400 with body status 404 for the second user. The script first reads and
compares the same object as its owner, so a missing object cannot satisfy that check. If the other
user gets HTTP 200, inspect existing policies and bucket visibility before exporting sensitive data.
