Cloud Storage · 2026-07-23 · Cloud Storage arc

MCP Tools for Cloud Storage: Auth and Credential Spectrum, Presigned URL Lifetime Constraints, and Access Control Models

Five cloud storage systems — AWS S3, Google Cloud Storage, Azure Blob Storage, DigitalOcean Spaces, and Supabase Storage — all solve the same problem (durable object storage with programmatic access) while making fundamentally different decisions about how credentials are acquired and rotated, how time-limited access URLs are generated and bounded, and how access control is modelled. These differences matter immediately when building MCP storage tools: the failure modes from conflating them are silent, environment-specific, and expensive to debug in production. S3 presigned URLs expire sooner than you configured when signed with a temporary role credential — the expiresIn parameter is capped by the signing credential's session duration (not the parameter value itself), so a 7-day presigned URL signed with an ECS task role credential expires in whatever time the role session has left, with no error at signing time and a confusing expired-signature response at access time. GCS signed URL generation requires the iam.serviceAccounts.signBlob permission on the signing service account — on GCE and GKE pods using the default compute service account, that permission is absent by default, and the SDK call fails with "SigningError: Cannot sign data without auth.credentials.private_key" rather than a permission error, which sends engineers looking at the wrong problem. Azure Blob Storage's RBAC model has a management plane and a data plane that are completely separate — an Azure subscription Contributor can manage the storage account (see it in the portal, configure firewall rules) but cannot read a single byte of blob data without a data-plane role (Storage Blob Data Reader minimum), and this mismatch produces "AuthorizationPermissionMismatch" errors that look like credential failures but are actually RBAC assignment failures. DigitalOcean Spaces has two endpoints — the origin (bucket.region.digitaloceanspaces.com) and the CDN (bucket.region.cdn.digitaloceanspaces.com) — and the CDN strips query parameters before forwarding to origin, so a presigned URL routed through the CDN endpoint loses its signature and returns 403, while a public object's origin URL bypasses CDN caching entirely; the correct mental model requires always routing writes and presigned access through the origin. Supabase Storage enforces Row-Level Security at the Postgres layer of its storage.objects table — S3 writes the bytes successfully, but the storage API returns a 400 RLS policy violation because Postgres denied the metadata row insert; the fix is to use the service_role key server-side (which bypasses RLS) rather than the anon key, and the distinction matters because both keys authenticate successfully but with completely different effective permissions. This post covers all three patterns with working code for each system, a composite health probe comparison table, and a storage selection guide for MCP server use cases.

TL;DR

Five storage systems, three patterns. (1) Auth and credential resolution: AWS S3 — credential chain: code → AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars → ~/.aws/credentials → EC2 instance metadata → ECS task role; omit explicit credentials on ECS/Lambda and let the SDK resolve the task role automatically; new S3Client({ region: process.env.AWS_REGION }) for production; add forcePathStyle: true only for S3-compatible endpoints (MinIO, LocalStack, R2) — AWS deprecated path-style and new buckets require virtual-hosted style; GCS — Application Default Credentials: GOOGLE_APPLICATION_CREDENTIALS env var → gcloud auth application-default login → GCE/GKE/Cloud Run metadata server; on GCE/GKE with the default compute SA, file.getSignedUrl() requires adding roles/iam.serviceAccountTokenCreator to the SA on itself or the SDK returns "SigningError: Cannot sign data without auth.credentials.private_key"; Azure BlobDefaultAzureCredential resolves: AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID → workload identity → managed identity → Azure CLI token; never use connection strings in production (they encode the account key granting unrestricted access); RBAC data-plane roles (Storage Blob Data Contributor) are separate from management-plane roles (Contributor); DO Spaces — account-level Spaces access keys (NOT DigitalOcean API tokens); one key pair accesses all Spaces in the account with no bucket-scope restriction; new S3Client({ region, endpoint: 'https://nyc3.digitaloceanspaces.com', credentials }); Supabase Storage — use SUPABASE_SERVICE_ROLE_KEY server-side (bypasses RLS on storage.objects), never SUPABASE_ANON_KEY server-side (subject to RLS, fails on private buckets without explicit policies). (2) Presigned URL lifetime constraints: S3 — getSignedUrl(client, new GetObjectCommand({ Bucket, Key }), { expiresIn: 3600 }); critical: when signing with temporary credentials (ECS task role, EC2 instance profile, STS AssumeRole), effective URL expiry is min(expiresIn, role_session_remaining) not expiresIn alone; signing with IAM user long-term keys has no session ceiling; GCS — file.getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 3600000 }); V4 max is 7 days; on GCE using the default compute SA, signing requires roles/iam.serviceAccountTokenCreator on the SA itself; Azure Blob — three SAS types: Account SAS (all services), Service SAS (one container/blob, signed with account key), User Delegation SAS (signed with Azure AD key, preferred, max 7 days); wrong resource type → "Signature did not match"; DO Spaces — presigned URLs must use the origin endpoint, not the CDN — CDN strips query params (including signature) before forwarding, returning 403; Supabase Storage — supabase.storage.from('bucket').createSignedUrl(path, expiresInSeconds) returns an opaque token that cannot be re-signed or extended — generate a new URL to renew access. (3) Access control models: S3 — layered: bucket policy + IAM identity policies + Block Public Access override; modern buckets use Object Ownership (BucketOwnerEnforced) disabling per-object ACLs; GCS — uniform bucket-level access (IAM bindings only, no per-object ACLs — irreversible after 90 days) vs fine-grained ACL (per-object grants); Azure Blob — data-plane RBAC roles (Storage Blob Data Reader/Contributor/Owner) orthogonal to container public access level (None, Blob, Container); DO Spaces — binary: public-read or private ACL, no bucket policies, no IAM-style expressions; Supabase Storage — Postgres RLS policies on storage.objects table, bucket public: true for CDN access (separate from RLS), switching private→public changes URL format breaking existing signed URLs.

Pattern 1: The Auth and Credential Resolution Spectrum

Cloud storage authentication looks deceptively similar across platforms — all five use some form of API key or access key to sign requests. The deep differences are in how credentials are acquired, how they rotate, and what scope they grant. Getting credential resolution wrong produces failures that are environment-specific: the same MCP server code that works with hardcoded development credentials silently fails in production when the credential source changes to an IAM role or a managed identity.

AWS S3: Credential Chain Resolution and Temporary Credentials

The AWS SDK resolves credentials in a fixed ordered chain: explicit credentials in code → environment variables (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN) → shared credentials file (~/.aws/credentials, respecting AWS_PROFILE) → AWS SSO → EC2 instance metadata → ECS task role → Cognito identity. For MCP servers deployed on ECS or Lambda, the correct pattern is to omit explicit credentials entirely — the SDK handles task/execution role rotation transparently.

import { S3Client } from '@aws-sdk/client-s3';
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';

// Production: let the credential chain resolve automatically
const s3 = new S3Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  // forcePathStyle: true — only for MinIO, LocalStack, Cloudflare R2 via S3 API
  // AWS deprecated path-style access; virtual-hosted is now required for new buckets
});

// Verify credential validity at startup (separate from bucket check)
async function verifyAWSCredentials() {
  const sts = new STSClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
  const identity = await sts.send(new GetCallerIdentityCommand({}));
  console.log(`AWS credentials valid — caller ARN: ${identity.Arn}`);
  return identity;
}

The most critical S3 credential behaviour for MCP servers is the intersection of temporary credentials and presigned URL signing. When the MCP server generates a presigned URL using its ECS task role credentials, the URL's effective expiry is bounded by the task role session — not the expiresIn parameter. A 7-day presigned URL signed with an ECS task role credential that has 3 hours left on its session will expire in 3 hours. The signing API does not warn about this at generation time, and the access failure manifests as an expired signature error that resembles a time drift problem rather than a credential scope issue.

GCS: Application Default Credentials and the signBlob Permission Gap

Google Cloud Storage uses Application Default Credentials (ADC) — a chain that resolves from GOOGLE_APPLICATION_CREDENTIALS env var → gcloud user credentials → the GCE/GKE/Cloud Run metadata server. The credential chain works seamlessly for most operations, but signed URL generation requires the signing credential's private key to be directly available or the IAM signBlob API to be callable. On GCE/GKE using the default compute service account, the private key is not directly available, so the SDK calls IAM.signBlob — which requires the iam.serviceAccounts.signBlob permission on the service account itself, not granted by default.

import { Storage } from '@google-cloud/storage';

// ADC — resolves automatically on GCP infrastructure
const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID });

async function verifyGCSAccess() {
  const bucket = storage.bucket(process.env.GCS_BUCKET!);
  try {
    const [exists] = await bucket.exists();
    if (!exists) throw new Error(`Bucket ${process.env.GCS_BUCKET} does not exist`);
    const [meta] = await bucket.getMetadata();
    console.log(`GCS bucket: uniform access=${meta.iamConfiguration?.uniformBucketLevelAccess?.enabled}, versioning=${meta.versioning?.enabled}`);
  } catch (err: any) {
    if (err.message.includes('Could not load the default credentials')) {
      throw new Error('GCS: run `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS');
    }
    throw err;
  }
}

// For signed URL generation on GCE/GKE with default compute SA:
// Fix: grant roles/iam.serviceAccountTokenCreator to the SA on itself
//   gcloud iam service-accounts add-iam-policy-binding SA_EMAIL \
//     --role=roles/iam.serviceAccountTokenCreator \
//     --member="serviceAccount:SA_EMAIL"
// Or use explicit key file for signing:
const storageForSigning = new Storage({
  projectId: process.env.GCP_PROJECT_ID,
  keyFilename: process.env.GCS_SIGNING_KEY_FILE,
});

Azure Blob Storage: The Management Plane / Data Plane RBAC Split

Azure Blob Storage separates management-plane and data-plane access with independent RBAC role assignments. Management-plane roles (Contributor, Reader, Owner on the subscription or resource group) control storage account configuration and portal visibility. Data-plane roles (Storage Blob Data Reader, Storage Blob Data Contributor, Storage Blob Data Owner) control actual blob read, write, and delete. An engineer with Contributor on the subscription can see the container in the portal but cannot download a single blob — and the error is "AuthorizationPermissionMismatch" which looks identical to a credential configuration failure.

import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob';
import { DefaultAzureCredential } from '@azure/identity';

const STORAGE_ACCOUNT = process.env.AZURE_STORAGE_ACCOUNT!;
const CONTAINER = process.env.AZURE_STORAGE_CONTAINER!;

// Production: DefaultAzureCredential resolves managed identity → service principal → Azure CLI
// Never use connection strings in production — they encode the account key
const credential = new DefaultAzureCredential();
const blobServiceClient = new BlobServiceClient(
  `https://${STORAGE_ACCOUNT}.blob.core.windows.net`,
  credential
);
const containerClient = blobServiceClient.getContainerClient(CONTAINER);

// SAS generation requires the storage account key (not available via managed identity)
const sharedKeyCredential = new StorageSharedKeyCredential(
  STORAGE_ACCOUNT,
  process.env.AZURE_STORAGE_ACCOUNT_KEY!  // fetch from Azure Key Vault in production
);

async function checkAzureAuth() {
  try {
    const exists = await containerClient.exists();
    return { ok: true, containerExists: exists };
  } catch (err: any) {
    if (err.code === 'AuthorizationPermissionMismatch') {
      throw new Error(`Azure: managed identity authenticated but lacks Storage Blob Data Reader data-plane role on container ${CONTAINER}`);
    }
    throw err;
  }
}

DigitalOcean Spaces: Account-Level Keys and the Origin/CDN Endpoint Distinction

DigitalOcean Spaces access keys are account-level — not bucket-scoped. A single Spaces key pair grants access to all Spaces buckets in the DigitalOcean account. There is no IAM-style policy to restrict a key to one bucket or prefix, which means a compromised Spaces key exposes all storage in the account.

import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';

const SPACES_REGION = process.env.SPACES_REGION ?? 'nyc3';
const SPACES_BUCKET = process.env.SPACES_BUCKET!;

// Spaces uses the AWS S3 SDK with a regional endpoint override
// No forcePathStyle needed — Spaces uses virtual-hosted style
const spaces = new S3Client({
  region: SPACES_REGION,
  endpoint: `https://${SPACES_REGION}.digitaloceanspaces.com`,  // origin endpoint
  credentials: {
    accessKeyId: process.env.SPACES_ACCESS_KEY!,   // from DO Dashboard → API → Spaces Keys
    secretAccessKey: process.env.SPACES_SECRET_KEY!,  // distinct from DigitalOcean API token
  },
});

// Two distinct URL types for the same object:
const originUrl = (key: string) =>
  `https://${SPACES_BUCKET}.${SPACES_REGION}.digitaloceanspaces.com/${key}`;  // write here
const cdnUrl = (key: string) =>
  `https://${SPACES_BUCKET}.${SPACES_REGION}.cdn.digitaloceanspaces.com/${key}`;  // read cached

async function verifySpacesAccess() {
  await spaces.send(new HeadBucketCommand({ Bucket: SPACES_BUCKET }));
  console.log(`Spaces: ${SPACES_BUCKET} accessible via origin endpoint in ${SPACES_REGION}`);
}

Supabase Storage: service_role vs anon Key and RLS-Enforced Access

Supabase Storage stores object bytes in S3-compatible storage and records metadata (bucket, path, owner, size, MIME type) in the storage.objects Postgres table. RLS policies on that table enforce access control at the metadata layer. When a storage operation uses the anon key, Supabase evaluates the caller's JWT claims against the storage.objects RLS policies — if no policy permits the operation, the request returns 400 "new row violates row-level security policy" even though the bytes may have been written to the underlying S3 storage already. The service_role key carries a JWT with role: service_role which bypasses RLS entirely.

import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = process.env.SUPABASE_URL!;

// Server-side: service_role key bypasses RLS — correct for backend MCP storage tools
const supabase = createClient(SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
  auth: { persistSession: false, autoRefreshToken: false },
});

// If you need per-user RLS enforcement (user's permissions apply to storage):
function createUserScopedClient(userJwt: string) {
  return createClient(SUPABASE_URL, process.env.SUPABASE_ANON_KEY!, {
    global: { headers: { Authorization: `Bearer ${userJwt}` } },
    auth: { persistSession: false },
  });
}

// Startup check: verifies service role key and that project is not paused
async function verifySupabaseAccess() {
  const { data, error } = await supabase.storage.listBuckets();
  if (error) {
    if (error.message.includes('paused')) throw new Error('Supabase project is paused — activate via dashboard');
    if (error.message.includes('JWT')) throw new Error('Supabase: invalid service role key');
    throw new Error(`Supabase Storage auth failed: ${error.message}`);
  }
  console.log(`Supabase Storage: ${data.length} buckets accessible`);
}

Pattern 2: Presigned URL Generation and Lifetime Constraints

Presigned URLs are the universal pattern for granting time-limited access to private objects without routing requests through the MCP server. Every platform supports this pattern, but the constraints on URL lifetime differ dramatically and have silent failure modes that only appear under specific deployment conditions.

AWS S3: The Temporary Credential Session Expiry Ceiling

S3 presigned URLs encode their signature in query parameters (X-Amz-Signature, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Security-Token). When the MCP server generates a presigned URL using a temporary credential (ECS task role, EC2 instance profile, STS AssumeRole), S3 validates the security token's session when the presigned URL is accessed. If the session has expired, the signature is invalid regardless of the X-Amz-Expires value. The effective URL lifetime is min(expiresIn, token_session_remaining), and there is no warning at signing time when these diverge.

import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

// Presigned download URL
// With temp creds (ECS task role): effective expiry = min(expiresIn, role_session_remaining)
async function getDownloadUrl(key: string, expiresInSeconds = 3600): Promise {
  return getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key }),
    { expiresIn: expiresInSeconds }
  );
}

// Presigned upload URL — client sends PUT directly (no server proxy)
async function getUploadUrl(key: string, contentType: string, expiresInSeconds = 900): Promise {
  return getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      ContentType: contentType,
      // Client must send matching Content-Type header or S3 rejects the PUT
    }),
    { expiresIn: expiresInSeconds }
  );
}

// Presigned POST for browser uploads with server-enforced size and type policy
async function getPresignedPost(key: string, maxSizeMB: number) {
  return createPresignedPost(s3, {
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    Conditions: [
      ['content-length-range', 1, maxSizeMB * 1024 * 1024],
      ['starts-with', '$Content-Type', 'image/'],
    ],
    Expires: 600,  // 10 minutes
    // Returns { url, fields } — fields must be included as form fields before the file part
  });
}

// For long-lived presigned URLs without session ceiling:
// Use an IAM user's long-term access key (no session expiry)
const s3ForSigning = new S3Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  credentials: {
    accessKeyId: process.env.SIGNER_ACCESS_KEY_ID!,
    secretAccessKey: process.env.SIGNER_SECRET_ACCESS_KEY!,
    // No sessionToken = long-term key with no session expiry ceiling
  },
});

GCS: V4 Signed URLs and the 7-Day Maximum

GCS V4 signed URLs (the current default in the Node.js SDK) have a hard maximum of 7 days (604,800 seconds) — not a session-based ceiling but a protocol limit. Requesting a URL expiring in 8 days returns an error at generation time. The practical constraint for MCP servers on GCE/GKE is the signBlob permission requirement on the default compute service account.

const bucket = storage.bucket(process.env.GCS_BUCKET!);

// V4 signed download URL — max 7 days
async function getGCSDownloadUrl(key: string, expiresInMinutes = 60): Promise {
  const [url] = await bucket.file(key).getSignedUrl({
    version: 'v4',
    action: 'read',
    expires: Date.now() + expiresInMinutes * 60 * 1000,
    // On GCE with default compute SA: requires roles/iam.serviceAccountTokenCreator on the SA
    // If missing: "SigningError: Cannot sign data without auth.credentials.private_key"
  });
  return url;
}

// Signed upload URL — client sends PUT directly to GCS
async function getGCSUploadUrl(key: string, contentType: string, expiresInMinutes = 15): Promise {
  const [url] = await bucket.file(key).getSignedUrl({
    version: 'v4',
    action: 'write',
    expires: Date.now() + expiresInMinutes * 60 * 1000,
    contentType,  // client must send matching Content-Type on PUT
  });
  return url;
}

// Conditional write — optimistic concurrency via generation numbers
// Versioning creates a new generation on every write (not a new key)
async function conditionalWrite(key: string, data: Buffer, expectedGeneration?: number) {
  const file = bucket.file(key);
  await file.save(data, {
    contentType: 'application/octet-stream',
    preconditionOpts: expectedGeneration !== undefined
      ? { ifGenerationMatch: expectedGeneration }
      : { ifGenerationMatch: 0 },  // 0 = create-if-not-exists (fail if any live version exists)
  });
}

Azure Blob Storage: SAS Token Type Hierarchy

Azure Blob Storage has three distinct SAS token types: Account SAS (all storage services), Service SAS (one container or blob, signed with the storage account key), and User Delegation SAS (one container or blob, signed with a temporary Azure AD key). The critical failure mode is constructing a SAS token with the wrong resource type. A Service SAS scoped to a blob used for a container listing request returns "Signature did not match" rather than a scope error. The ss (signedService) query parameter identifies the scope and is the first field to check when signature mismatch errors appear.

import { generateBlobSASQueryParameters, BlobSASPermissions, SASProtocol } from '@azure/storage-blob';

// Service SAS for blob download — signed with account key
async function getAzureBlobDownloadSAS(blobName: string, expiryMinutes = 60): Promise {
  const sasToken = generateBlobSASQueryParameters(
    {
      containerName: CONTAINER,
      blobName,
      startsOn: new Date(),
      expiresOn: new Date(Date.now() + expiryMinutes * 60 * 1000),
      permissions: BlobSASPermissions.parse('r'),
      protocol: SASProtocol.Https,
    },
    sharedKeyCredential
  ).toString();
  return `https://${STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}/${blobName}?${sasToken}`;
}

// User Delegation SAS — preferred: signed with Azure AD key, no account key required
// Max 7 days. Requires Microsoft.Storage/.../generateUserDelegationKey/action permission.
async function getAzureUserDelegationSAS(blobName: string): Promise {
  const startsOn = new Date();
  const expiresOn = new Date(Date.now() + 60 * 60 * 1000);
  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
  const sasToken = generateBlobSASQueryParameters(
    { containerName: CONTAINER, blobName, startsOn, expiresOn,
      permissions: BlobSASPermissions.parse('r'), protocol: SASProtocol.Https },
    userDelegationKey,
    STORAGE_ACCOUNT
  ).toString();
  return `https://${STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}/${blobName}?${sasToken}`;
}

// Write-if-not-exists — conditional PUT using ETag condition
async function uploadIfNotExists(key: string, data: Buffer, contentType: string) {
  const blockBlobClient = containerClient.getBlockBlobClient(key);
  try {
    await blockBlobClient.uploadData(data, {
      blobHTTPHeaders: { blobContentType: contentType },
      conditions: { ifNoneMatch: '*' },  // 409 Conflict if blob already exists
    });
    return { created: true };
  } catch (err: any) {
    if (err.statusCode === 409) return { created: false };
    throw err;
  }
}

DigitalOcean Spaces: Origin Endpoint Required for Presigned URLs

The Spaces CDN (backed by Cloudflare) strips query string parameters before forwarding requests to the origin. An S3-presigned URL encodes its signature as query parameters. If the presigned URL points to the CDN endpoint (bucket.region.cdn.digitaloceanspaces.com), those parameters are stripped before reaching the origin — the request arrives signature-free and returns 403. The URL must always use the origin endpoint (bucket.region.digitaloceanspaces.com).

import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand, PutObjectCommand, PutObjectCommand as PutACLCommand } from '@aws-sdk/client-s3';

// Presigned download URL — MUST use origin endpoint (spaces client initialized to origin)
async function getSpacesDownloadUrl(key: string, expiresInSeconds = 3600): Promise {
  const url = await getSignedUrl(
    spaces,  // client endpoint: https://nyc3.digitaloceanspaces.com (origin)
    new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: key }),
    { expiresIn: expiresInSeconds }
  );
  // URL: https://BUCKET.nyc3.digitaloceanspaces.com/KEY?X-Amz-Signature=...
  // DO NOT rewrite to cdn.digitaloceanspaces.com — CDN strips query params → 403
  return url;
}

// Public objects: CDN URL is correct — no query string
function getPublicCdnUrl(key: string): string {
  return cdnUrl(key);  // Only valid if object has ACL: 'public-read'
}

// Upload with public ACL for CDN access
async function uploadPublic(key: string, data: Buffer, contentType: string) {
  const { PutObjectCommand } = await import('@aws-sdk/client-s3');
  await spaces.send(new PutObjectCommand({
    Bucket: SPACES_BUCKET,
    Key: key,
    Body: data,
    ContentType: contentType,
    ACL: 'public-read',        // binary: 'public-read' or 'private' — no fine-grained ACLs
    CacheControl: 'public, max-age=86400',
  }));
  return cdnUrl(key);
}

Supabase Storage: Opaque Signed URL Tokens

Supabase Storage signed URLs are opaque tokens — not HMAC-signed query parameters over bucket/key/expiry. The token encodes the access grant server-side. Unlike S3 where you re-sign the same object key with a new expiry using the same credentials, Supabase Storage signed URLs cannot be extended or renewed: you must generate a new one when the previous expires. The bucket public: true flag enables a separate permanent URL path that requires no token.

// Single signed URL for private bucket access
async function getSupabaseSignedUrl(bucket: string, path: string, expiresInSeconds = 3600): Promise {
  const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, expiresInSeconds);
  if (error) throw error;
  return data.signedUrl;
  // Opaque token — cannot be re-signed or extended; generate a new URL to renew
}

// Bulk signed URLs — single API call for N paths (more efficient than N individual calls)
async function getBulkSignedUrls(bucket: string, paths: string[], expiresInSeconds = 3600) {
  const { data, error } = await supabase.storage.from(bucket).createSignedUrls(paths, expiresInSeconds);
  if (error) throw error;
  return data.map(item => ({ path: item.path, url: item.signedUrl, error: item.error }));
}

// Signed upload URL — browser PUT directly without server proxy
async function getSupabaseUploadUrl(bucket: string, path: string) {
  const { data, error } = await supabase.storage.from(bucket).createSignedUploadUrl(path);
  if (error) throw error;
  return data;  // { signedUrl, token, path }
}

// Public bucket: permanent non-expiring URL (no token)
function getSupabasePublicUrl(bucket: string, path: string): string {
  const { data } = supabase.storage.from(bucket).getPublicUrl(path);
  return data.publicUrl;
  // IMPORTANT: switching bucket private→public changes URL format, breaking existing signed URLs
}

// Image transforms (Pro plan only — silently ignored on Free plan)
function getResizedImageUrl(bucket: string, path: string, width: number, height: number): string {
  const { data } = supabase.storage.from(bucket).getPublicUrl(path, {
    transform: { width, height, resize: 'cover', format: 'webp', quality: 80 },
    // On Free plan: transform silently ignored, original image returned — no error
  });
  return data.publicUrl;
}

Pattern 3: Access Control Models — From IAM Policies to Postgres RLS

Each system uses a fundamentally different access control model. Understanding which model applies determines which operations are available, how to debug permission failures, and what the blast radius of a misconfiguration is.

AWS S3: Layered Policies with Block Public Access Override

S3 uses a layered system: bucket policies (JSON IAM-style documents applied to the bucket), IAM identity policies (attached to roles, users, groups), and object ACLs (deprecated for new buckets with BucketOwnerEnforced Object Ownership). S3 Block Public Access settings at the account and bucket level act as override guards — they can block public access even when bucket policies explicitly grant it. Always check Block Public Access settings when troubleshooting unexpected 403s on public objects.

import { PutBucketPolicyCommand, GetBucketPolicyStatusCommand,
         PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';

// Bucket policy restricting read to a specific IAM role
const mcpToolPolicy = {
  Version: '2012-10-17',
  Statement: [{
    Sid: 'AllowMCPToolAccess',
    Effect: 'Allow',
    Principal: { AWS: `arn:aws:iam::${process.env.AWS_ACCOUNT_ID}:role/mcp-server-role` },
    Action: ['s3:GetObject', 's3:PutObject', 's3:ListBucket'],
    Resource: [
      `arn:aws:s3:::${process.env.S3_BUCKET}`,
      `arn:aws:s3:::${process.env.S3_BUCKET}/uploads/*`,
    ],
  }],
};

// Lifecycle rule to abort incomplete multipart uploads (critical for cost control)
// Incomplete parts are invisible to listing but incur storage charges indefinitely
async function ensureMultipartCleanup(bucketName: string) {
  await s3.send(new PutBucketLifecycleConfigurationCommand({
    Bucket: bucketName,
    LifecycleConfiguration: {
      Rules: [{
        ID: 'abort-incomplete-multipart-uploads',
        Status: 'Enabled',
        Filter: { Prefix: '' },
        AbortIncompleteMultipartUploads: { DaysAfterInitiation: 7 },
      }],
    },
  }));
}

GCS: Uniform Bucket-Level Access vs Fine-Grained ACLs (Mutually Exclusive)

GCS enforces a choice between two mutually exclusive access modes. Fine-grained access allows per-object ACL grants (file.makePublic(), file.acl.add()) alongside IAM bindings. Uniform bucket-level access removes per-object ACLs entirely and relies exclusively on IAM bindings at the bucket level. Once uniform access is enabled and 90 days have elapsed, it cannot be reverted. The migration is intentionally one-way to prevent ACL/policy split-brain access control.

// Check which access mode a bucket uses
async function checkGCSAccessMode() {
  const bucket = storage.bucket(process.env.GCS_BUCKET!);
  const [meta] = await bucket.getMetadata();
  const uniformEnabled = meta.iamConfiguration?.uniformBucketLevelAccess?.enabled ?? false;
  const lockedUntil = meta.iamConfiguration?.uniformBucketLevelAccess?.lockedTime;
  return {
    mode: uniformEnabled ? 'uniform-iam-only' : 'fine-grained-acl',
    revertWindow: uniformEnabled && lockedUntil ? new Date(lockedUntil) : null,
    // If revertWindow is in the past, uniform mode is permanent — cannot be reverted
  };
}

// Uniform-access bucket: use IAM bindings (not file.makePublic())
async function grantBucketRead(email: string, type: 'user' | 'serviceAccount' | 'group') {
  const bucket = storage.bucket(process.env.GCS_BUCKET!);
  const [policy] = await bucket.iam.getPolicy({ requestedPolicyVersion: 3 });
  policy.bindings.push({
    role: 'roles/storage.objectViewer',
    members: [`${type}:${email}`],
  });
  await bucket.iam.setPolicy(policy);
}

// Fine-grained ACL bucket: per-object public access
async function makeObjectPublic(key: string) {
  await storage.bucket(process.env.GCS_BUCKET!).file(key).makePublic();
  return `https://storage.googleapis.com/${process.env.GCS_BUCKET}/${key}`;
  // On a uniform-access bucket: this call returns an error — use IAM bindings instead
}

Azure Blob Storage: Container Public Access Level and Data-Plane RBAC

Azure Blob Storage access control has two orthogonal dimensions: RBAC role assignments on authenticated identities, and container public access level for anonymous access. The public access level is set per container: None (fully private), blob (anonymous read of individual blobs by URL), or container (anonymous read and list — avoid: exposes all object keys). SAS tokens provide a third access path independent of RBAC or public access settings.

import { PublicAccessType } from '@azure/storage-blob';

// Container public access levels — set at container, not object level
async function setContainerPublicAccess(level: PublicAccessType | undefined) {
  await containerClient.setAccessPolicy(level);
  // undefined / 'none' = fully private (auth required for all access)
  // 'blob' = anonymous GET by URL (no listing — safer for media)
  // 'container' = anonymous GET + list (avoid — exposes all blob names)
}

// Check container's current public access setting
async function checkContainerAccess() {
  const props = await containerClient.getProperties();
  return {
    publicAccess: props.blobPublicAccess ?? 'none',
    // RBAC assignment is not inspectable from the storage API — check Azure Portal
    // or use az role assignment list --scope {container-resource-id}
  };
}

// Assign data-plane RBAC role to a managed identity (requires management-plane permissions)
// az role assignment create \
//   --assignee-object-id {managed-identity-object-id} \
//   --role "Storage Blob Data Contributor" \
//   --scope "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{acct}/blobServices/default/containers/{container}"

DigitalOcean Spaces: Binary ACLs with No Bucket Policies

Spaces access control is the simplest and most limited of the five systems. Object ACLs support exactly two values: private (default, presigned URL required) and public-read (accessible at CDN URL). There are no bucket policies, no IAM-style condition expressions, and no IP restriction lists. Presigned PUT uploads always create private objects — a separate PutObjectAclCommand is required to make them public after upload.

import { PutObjectAclCommand } from '@aws-sdk/client-s3';

// Upload private (requires presigned URL to access)
async function uploadPrivate(key: string, data: Buffer, contentType: string) {
  const { PutObjectCommand } = await import('@aws-sdk/client-s3');
  await spaces.send(new PutObjectCommand({
    Bucket: SPACES_BUCKET,
    Key: key,
    Body: data,
    ContentType: contentType,
    ACL: 'private',  // default; required for clarity
  }));
  return { originUrl: originUrl(key) };
}

// Upload public (accessible at CDN URL without authentication)
async function uploadPublicFile(key: string, data: Buffer, contentType: string) {
  const { PutObjectCommand } = await import('@aws-sdk/client-s3');
  await spaces.send(new PutObjectCommand({
    Bucket: SPACES_BUCKET,
    Key: key,
    Body: data,
    ContentType: contentType,
    ACL: 'public-read',
    CacheControl: 'public, max-age=86400',
  }));
  return { cdnUrl: cdnUrl(key) };
}

// Make an existing private object public (after a presigned upload)
async function makeObjectPublicAfterUpload(key: string) {
  await spaces.send(new PutObjectAclCommand({ Bucket: SPACES_BUCKET, Key: key, ACL: 'public-read' }));
  return cdnUrl(key);
}

Supabase Storage: Postgres RLS as the Access Control Layer

Supabase Storage's access control is implemented as Postgres RLS policies on the storage.objects table — the only system in this arc where file access control is expressed in SQL. This means storage policies share the same language and debugging tools as your application's other RLS rules, and storage access failures can be diagnosed with standard Postgres query techniques. The bucket public: true flag controls CDN public access at the HTTP layer, entirely bypassing RLS for GET requests to the public URL endpoint.

-- RLS policy: authenticated users can read only their own uploads
-- (path convention: {user_id}/{filename})
CREATE POLICY "user_reads_own_files" ON storage.objects
  FOR SELECT USING (
    bucket_id = 'user-uploads' AND
    auth.uid() = (storage.foldername(name))[1]::uuid
  );

CREATE POLICY "user_uploads_own_files" ON storage.objects
  FOR INSERT WITH CHECK (
    bucket_id = 'user-uploads' AND
    auth.uid() = (storage.foldername(name))[1]::uuid
  );

-- MCP server using service_role key bypasses all RLS policies (no auth.uid() evaluation)
-- MCP server using anon key (+ user JWT) is subject to these policies

-- Bucket public flag vs RLS: orthogonal dimensions
-- public: true → getPublicUrl() works (HTTP GET bypasses RLS)
-- public: false → only signed URLs or service_role calls work
-- Switching private→public changes URL format: /object/sign/ → /object/public/
-- Existing signed URL links for affected objects break on the switch
// Bucket creation — public flag is set at creation and controls CDN URL availability
async function createPrivateBucket(name: string) {
  const { data, error } = await supabase.storage.createBucket(name, {
    public: false,
    fileSizeLimit: 50 * 1024 * 1024,
    allowedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'],
  });
  if (error) throw error;
  return data;
}

async function createPublicBucket(name: string) {
  const { data, error } = await supabase.storage.createBucket(name, {
    public: true,  // all objects readable without authentication via public URL
  });
  if (error) throw error;
  return data;
}

Composite Health Probe Comparison

Each storage system requires a different minimal health probe. The table below maps probe operations to what they verify and what they miss — a single probe rarely covers the full failure surface:

System Minimal probe Verifies Does NOT verify
AWS S3 HeadBucketCommand + STS GetCallerIdentity Bucket accessible, credentials valid, caller ARN Write permission; presigned URL expiry ceiling with temp creds; multipart cleanup lifecycle rule
GCS bucket.exists() Bucket exists, ADC resolves, storage.buckets.get permission Signed URL generation (signBlob permission may be missing); write access; uniform vs ACL mode
Azure Blob containerClient.exists() Container exists, data-plane RBAC assigned (at least Reader) SAS token generation (requires account key separately); write permission; management vs data plane separation
DO Spaces HeadBucketCommand to origin endpoint Origin reachable, Spaces key valid, bucket exists CDN availability (probe CDN separately with a known public object's HEAD request); write permission
Supabase Storage supabase.storage.listBuckets() Service role key valid, Storage service reachable, project not paused RLS policy correctness for anon key operations; image transform plan tier; specific bucket write access
// Combined health probe across all five — runs in parallel, tolerates individual failures
async function checkAllStorageHealth() {
  const checks = await Promise.allSettled([
    checkS3Health(process.env.S3_BUCKET!),
    checkGCSHealth(),
    checkAzureBlobHealth(),
    checkSpacesHealth(),
    checkSupabaseStorageHealth(),
  ]);

  return {
    s3: checks[0].status === 'fulfilled' ? checks[0].value : { alive: false, error: String(checks[0].reason) },
    gcs: checks[1].status === 'fulfilled' ? checks[1].value : { alive: false, error: String(checks[1].reason) },
    azureBlob: checks[2].status === 'fulfilled' ? checks[2].value : { alive: false, error: String(checks[2].reason) },
    spaces: checks[3].status === 'fulfilled' ? checks[3].value : { alive: false, error: String(checks[3].reason) },
    supabase: checks[4].status === 'fulfilled' ? checks[4].value : { alive: false, error: String(checks[4].reason) },
    allHealthy: checks.every(r => r.status === 'fulfilled'),
  };
}

Failure Mode Cross-Reference

The most operationally significant failure modes across the Cloud Storage arc and how to distinguish them:

Error / symptom System Root cause Fix
Presigned URL returns 403 before configured expiry S3 Signed with temp creds (ECS task role) whose session expired before expiresIn Use IAM user long-term key for signing; or generate short-lived URLs; or re-sign before session expires
"SigningError: Cannot sign data without auth.credentials.private_key" GCS GCE/GKE default compute SA lacks iam.serviceAccounts.signBlob permission Grant roles/iam.serviceAccountTokenCreator to the SA on itself; or use explicit key file for signing
"Signature did not match" on valid-looking SAS token Azure Blob SAS token resource type mismatch (blob SAS used for container request, or vice versa) Inspect the ss query param; regenerate with correct scope (Service SAS per blob, or set containerName only for container)
Presigned URL returns 403 when accessed via CDN hostname DO Spaces CDN endpoint strips query string params (including X-Amz-Signature) before forwarding to origin Always use origin endpoint in presigned URLs; CDN URL only for public-read objects without query strings
"new row violates row-level security policy" Supabase Storage Using anon key without RLS policy granting the operation on storage.objects Use service_role key server-side (bypasses RLS); or write an RLS policy matching the operation
"AuthorizationPermissionMismatch" Azure Blob Identity authenticated but lacks data-plane RBAC role on the container Assign Storage Blob Data Contributor at the container scope in addition to any management-plane role
Unexpected storage cost from invisible multipart uploads S3 No lifecycle rule to abort incomplete multipart uploads; parts accumulate without appearing in object listings Add AbortIncompleteMultipartUploads: { DaysAfterInitiation: 7 } lifecycle rule to every bucket used for multipart
Supabase Storage returning connection timeouts (not 4xx) Supabase Storage Supabase free plan project auto-paused after 1 week of inactivity Activate via Supabase dashboard; add keepalive probe at <7 day interval; upgrade to Pro plan for always-on
per-object ACL call returns error on GCS GCS Bucket has uniform bucket-level access enabled — per-object ACLs are disabled Use IAM bindings on the bucket instead of file.makePublic() or file.acl.add()

Platform Selection Guide for MCP Server Use Cases

MCP server use case Best fit Why
AWS-native deployment (ECS, Lambda, EC2) S3 Task role credential chain resolves without configuration; IAM policies for fine-grained per-prefix access; multipart for large files; strong read-after-write consistency since Dec 2020
GCP-native deployment (Cloud Run, GKE, App Engine) GCS ADC resolves from metadata server automatically; Workload Identity on GKE; V4 signed URLs up to 7 days; object versioning via generation numbers
Azure-native deployment (AKS, Container Apps, App Service) Azure Blob Managed identity resolves via DefaultAzureCredential; fine-grained data-plane RBAC; byte-range download for partial content; User Delegation SAS preferred over Service SAS
DigitalOcean App Platform or Droplet DO Spaces $5/month flat with 250 GB storage and 1 TB bandwidth; CDN included; S3-compatible SDK works unchanged
Supabase-based backend with Auth integration Supabase Storage RLS on storage.objects uses auth.uid() for per-user file isolation; same policy language as application data; integrated with Supabase Auth JWTs
Serving public media at edge globally DO Spaces Cloudflare CDN included in flat rate; public-read ACL objects cached at edge; cache-control headers for TTL control; cf-cache-status header in probes
Per-user file uploads with per-user access control Supabase Storage RLS on storage.objects enforces isolation; service_role key for admin operations bypassing RLS; signed URLs for time-limited user access
S3-compatible self-hosted (MinIO, Ceph, LocalStack) S3 + forcePathStyle AWS S3 SDK with forcePathStyle: true and custom endpoint override works against any S3-compatible service

Related guides