Skip to main content
Multi-Cloud · lesson 4 of 8
Multi-Cloud intermediate Lesson 4 of 8

Object Storage: One Script, Three APIs

Upload a file, list a bucket, and generate a signed URL against S3, Azure Blob Storage, and GCS — one small script per cloud, with real output from each.

By SkillByExample · Published

cloud aws azure gcp object-storage python

Same three operations — upload a file, list what’s in the bucket, generate a time-limited signed URL for it — against S3, Azure Blob Storage, and GCS. Same shape, three SDKs, three authentication models underneath.

AWS: boto3 and S3

# s3_ops.py
import boto3

s3 = boto3.client("s3")
bucket = "reports-bucket"

s3.upload_file("quarterly.csv", bucket, "2026/quarterly.csv")

for obj in s3.list_objects_v2(Bucket=bucket, Prefix="2026/")["Contents"]:
    print(obj["Key"], obj["Size"], obj["LastModified"])

url = s3.generate_presigned_url(
    "get_object", Params={"Bucket": bucket, "Key": "2026/quarterly.csv"}, ExpiresIn=900
)
print(url)
$ python s3_ops.py
2026/quarterly.csv 48213 2026-09-13 10:04:02+00:00
https://reports-bucket.s3.amazonaws.com/2026/quarterly.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260913%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260913T100415Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=b1f4a2e9c...

generate_presigned_url never makes a network call — it computes a SigV4 signature locally using the credentials boto3 already holds, which is why it returns instantly even without network access. The URL is only as safe as the ExpiresIn you pick; 900 seconds (15 minutes) here.

Azure: azure-storage-blob and Blob Storage

# blob_ops.py
from datetime import datetime, timedelta
from azure.storage.blob import BlobServiceClient, generate_blob_sas, BlobSasPermissions

conn_str = "DefaultEndpointsProtocol=https;AccountName=reportsstore;AccountKey=...;EndpointSuffix=core.windows.net"
service = BlobServiceClient.from_connection_string(conn_str)
container = service.get_container_client("reports")

container.upload_blob(name="2026/quarterly.csv", data=open("quarterly.csv", "rb"), overwrite=True)

for blob in container.list_blobs(name_starts_with="2026/"):
    print(blob.name, blob.size, blob.last_modified)

sas = generate_blob_sas(
    account_name="reportsstore", container_name="reports", blob_name="2026/quarterly.csv",
    account_key="...", permission=BlobSasPermissions(read=True),
    expiry=datetime.utcnow() + timedelta(minutes=15),
)
print(f"https://reportsstore.blob.core.windows.net/reports/2026/quarterly.csv?{sas}")
$ python blob_ops.py
2026/quarterly.csv 48213 2026-09-13 10:05:11+00:00
https://reportsstore.blob.core.windows.net/reports/2026/quarterly.csv?sv=2025-05-05&st=2026-09-13T10%3A05%3A20Z&se=2026-09-13T10%3A20%3A20Z&sr=b&sp=r&sig=P0aB3f...

Same idea, more parameters spelled out explicitly: generate_blob_sas needs the account name, container, blob path, and expiry all as separate arguments rather than reading them off a client object, and the permission is an explicit BlobSasPermissions(read=True) rather than being implied by which API method you called.

GCP: google-cloud-storage and GCS

# gcs_ops.py
import datetime
from google.cloud import storage

client = storage.Client()
bucket = client.bucket("reports-bucket")

blob = bucket.blob("2026/quarterly.csv")
blob.upload_from_filename("quarterly.csv")

for b in client.list_blobs("reports-bucket", prefix="2026/"):
    print(b.name, b.size, b.updated)

url = blob.generate_signed_url(
    version="v4", expiration=datetime.timedelta(minutes=15), method="GET"
)
print(url)
$ python gcs_ops.py
2026/quarterly.csv 48213 2026-09-13 10:06:03.221000+00:00
https://storage.googleapis.com/reports-bucket/2026/quarterly.csv?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=pipeline%40my-project.iam.gserviceaccount.com%2F20260913%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260913T100603Z&X-Goog-Expires=900&X-Goog-SignedHeaders=host&X-Goog-Signature=7ac1e9...

Signing here requires a service account with a private key (or iam.serviceAccounts.signBlob permission if using a keyless identity) — generate_signed_url fails outright under Application Default Credentials from a plain user login, which is the one auth wrinkle that trips people up moving from S3/Blob to GCS.

What actually stays the same

OperationAWS (boto3)Azure (azure-storage-blob)GCP (google-cloud-storage)
Uploadclient.upload_file()container_client.upload_blob()blob.upload_from_filename()
Listlist_objects_v2()["Contents"]container_client.list_blobs()client.list_blobs()
Time-limited URLgenerate_presigned_url()generate_blob_sas()blob.generate_signed_url()
AuthAccess key / assumed roleConnection string / DefaultAzureCredentialService account key / ADC

The operation names differ, the auth plumbing differs more, but the shape — client, container/bucket handle, per-object operations, a signing call that needs an expiry — is identical enough that a wrapper with one method signature per operation can hide all three behind a single interface your application code calls.

Practice

1. Write a `sign_url(cloud, bucket, key, minutes)` function that dispatches to the right SDK based on the `cloud` argument.
def sign_url(cloud, bucket, key, minutes=15):
    if cloud == "aws":
        return boto3.client("s3").generate_presigned_url(
            "get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=minutes * 60)
    if cloud == "gcp":
        blob = storage.Client().bucket(bucket).blob(key)
        return blob.generate_signed_url(version="v4", expiration=timedelta(minutes=minutes))
    raise ValueError(cloud)

This is the “one script” version the lesson title promises — three implementations behind one call site, which is what actually gets used in a real codebase.

2. Set `ExpiresIn=900` on the S3 URL, wait 16 minutes, then try to fetch it.
$ curl -sI "https://reports-bucket.s3.amazonaws.com/2026/quarterly.csv?...&X-Amz-Expires=900&..."
HTTP/1.1 403 Forbidden
<Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>

The signature itself never expires as a cryptographic object — the server checks the embedded X-Amz-Date and X-Amz-Expires against the current time and rejects it, which is why clock skew between signer and server occasionally causes a “not yet valid” error too.

3. List objects with a prefix that matches nothing on each cloud and compare the three empty-result shapes.

boto3’s list_objects_v2 omits the "Contents" key entirely from the response when nothing matches (a bare KeyError if you assume it’s always there); Azure’s list_blobs() and GCP’s list_blobs() both return an empty iterable instead — a real, easy-to-miss difference if you’re writing one wrapper across all three.

4. Generate a write-only (upload) signed URL on GCS instead of a read URL.
url = blob.generate_signed_url(
    version="v4", expiration=datetime.timedelta(minutes=15), method="PUT",
    content_type="text/csv",
)

method="PUT" plus a matching content_type is required — a client uploading through this URL must send the exact Content-Type header baked into the signature, or GCS rejects the upload with a signature mismatch even though the URL itself hasn’t expired.

Next: the same “allow inbound 443, deny everything else” rule, expressed as an AWS security group, an Azure NSG rule, and a GCP firewall rule.

Frequently Asked Questions

Is a presigned S3 URL the same thing as a SAS token?
Conceptually yes — both are a time-limited, credential-free URL that grants a specific permission (read, write) on a specific object without exposing your account credentials. S3 calls it a presigned URL, Azure calls it a SAS (Shared Access Signature) token, and GCS calls it a signed URL. All three encode an expiry and a signature computed with your credentials, so anyone with the link can use it until it expires, with no login required.
Do S3, Blob Storage, and GCS use the same authentication model for their SDKs?
No, and this is where the three genuinely diverge. boto3 typically uses an IAM access key or an assumed role's temporary credentials. The Azure SDK typically uses a connection string or a DefaultAzureCredential chain (environment, managed identity, Azure CLI login). The GCS client library typically uses a service account key file or Application Default Credentials. The object operations converge; the auth plumbing underneath does not.
Which cloud's object storage is cheapest for a small file-hosting workload?
Storage cost itself is close on all three (roughly $0.020-$0.023/GB/month for standard/hot tiers). The real difference shows up in request pricing and egress once traffic grows — compare your actual read/write ratio and egress volume against each cloud's current pricing page rather than assuming storage cost dominates the bill.
Can I use the same code to talk to S3, Blob Storage, and GCS?
Not directly — the three SDKs are not interchangeable, and each has its own client, exceptions, and method names. What you can share is the shape: a small wrapper function per cloud with the same signature (upload, list, sign) lets the rest of your application call one interface while three implementations sit behind it, which is the more realistic version of "one script."