The moment you confirm a bucket is public, the clock is already running. You have no idea how long it's been running. Treat it like the incident it is: the first move is to stop the bleed by cutting public access, then work out what leaked and who needs to rotate what. Don't open with a leisurely inventory. Lock the door first; catalog the damage second.

This is an IR-style runbook for AWS S3, Azure Blob Storage, and Google Cloud Storage, ordered by urgency rather than by topic:

  • First hour: cut public access (Step 1), then identify what was exposed (Step 2).
  • First 24 hours: rotate every secret that was reachable (Step 3), then sweep for other public buckets and stale grants (Step 4).
  • Ongoing: stand up detection so a regression pages you (Step 5), and replace any legitimate public access with signed-URL hygiene (Step 6).

Step 1: Cut public access now

This is the only step with a deadline measured in minutes. Apply the highest-level block your provider offers (account or project scope, not just the bucket) so a single forgotten ACL can't keep the door open. Worry about cleaning up the underlying policies in Step 4; right now you just want the 403.

AWS S3: enable Block Public Access:

# At the account level (preferred, affects all buckets)
aws s3control put-public-access-block \
  --account-id <account-id> \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# At the bucket level (per-bucket override)
aws s3api put-public-access-block \
  --bucket <bucket-name> \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

Azure Blob: disable anonymous access at the account level:

az storage account update \
  --name <account-name> \
  --resource-group <rg-name> \
  --allow-blob-public-access false

GCS: enable Public Access Prevention:

gcloud storage buckets update gs://<bucket-name> \
  --public-access-prevention

Then prove it. An unauthenticated probe should now return a 403 or equivalent denial. Don't take the API's word for it. Paste the bucket URL into the open-bucket viewer to confirm the block is effective from the outside, which is the only perspective that matters to an attacker.

Step 2: Identify what was exposed

With the door shut, figure out what walked through it. This still belongs in the first hour, because the manifest drives everything that follows: it tells you which secrets to rotate and whether this is a notify-the-lawyers event.

  • List all objects in the bucket. This is the most important thing you do in this step. Capture the full object manifest as your record of what was reachable. It is the spine of the rest of the response: every credential you rotate, every disclosure decision you make, traces back to a line in this list. On S3: aws s3 ls s3://<bucket> --recursive > manifest.txt. On GCS: gcloud storage ls --recursive gs://<bucket>. On Azure: az storage blob list --container-name <container> --account-name <account>. Save it somewhere durable and timestamped, because object listings change.

  • Triage the manifest for secrets and sensitive data. Scan for the file classes that turn an exposure into a breach: database backups (.sql, .bak, .dump, .tar.gz), environment and config files (.env, config.json, secrets.yaml), Terraform state (terraform.tfstate, which embeds credentials in plaintext), log archives, and anything whose name carries key, secret, password, token, or credential. Flag each hit. It becomes a rotation task in Step 3.

  • Document the metadata you'll need for a disclosure call. Record the bucket's region, creation date, owning account or subscription, and the exact misconfiguration (which ACL, IAM binding, or policy did it). Note when public access was applied versus when the bucket was created. If you have to assess breach notification obligations, this is the evidence package.

Step 3: Rotate every secret that was reachable

You are now in the first-24-hours window. Treat every secret in that manifest as compromised. Don't wait for proof of download: your log retention is almost certainly shorter than the exposure window, so "no evidence of access" usually just means "no evidence either way." Assume it was taken and rotate it. Work the highest-blast-radius credentials first.

  • Cloud provider credentials (AWS access keys, GCP service account keys, Azure client secrets) come first. These are the keys that let an attacker pivot from your bucket into the rest of your account. Rotate them through the provider's IAM console, push the new credential to every application, CI/CD pipeline, and secrets manager that consumed the old one, and only then delete the old key. A service-account key that leaked in a terraform.tfstate file is exactly this kind of pivot, so don't treat it as a footnote.

  • Database credentials (connection strings, usernames, passwords): change the password and update every application that connects. For databases that support per-user credentials, revoke the exposed user outright and create a new one with a fresh password rather than reusing the username. A leaked username is a head start for credential stuffing.

  • Third-party API keys (payment processors, email services, mapping APIs): revoke and regenerate from each provider's dashboard, then pull the revoked key's usage logs if the vendor exposes them and look for request patterns you didn't make.

  • JWT signing secrets and OAuth client secrets: rotating these invalidates every session signed with the old secret, so plan a brief impact window or do a dual-key rotation if you need zero downtime, but rotate them. A leaked signing secret lets an attacker mint valid tokens indefinitely.

When you're done, sweep your source repos and config management for the old values. A credential that's been rotated but still sits in a checked-in config file will simply be re-exposed the next time that file gets pushed to storage.

Step 4: Audit ACLs, policies, and IAM bindings, then sweep for the next one

The account-level block from Step 1 stopped the bleeding, but it didn't remove the ACL, policy, or IAM binding that caused it. Those are still sitting there, ready to re-expose the bucket the day someone disables the block. Pull them out now. While you're in the console, assume this bucket isn't the only one. The same Terraform module or copy-pasted policy that exposed it has almost certainly exposed siblings.

AWS S3: audit bucket policies and ACLs:

# Check bucket policy for wildcard principals
aws s3api get-bucket-policy --bucket <bucket-name>
# Look for "Principal": "*" or "Principal": {"AWS": "*"} without Condition blocks

# Check ACL for AllUsers or AuthenticatedUsers grants
aws s3api get-bucket-acl --bucket <bucket-name>
# Look for Grantee URI containing AllUsers or AuthenticatedUsers

Remove any policy that grants Principal: "*" without a restrictive Condition block, and replace the ACL with a private grant using:

aws s3api put-bucket-acl --bucket <bucket-name> --acl private

Azure Blob: revoke broad SAS tokens. Now that account-level anonymous access is false (Step 1), the legacy az storage container set-permission --public-access off is moot, so skip it. The real exposure left here is SAS tokens. Tokens issued with broad scope (container-level read, long expiry) can't be revoked individually. Your only lever is rotating the storage account key they were signed with, which invalidates every token signed with that key at once. Plan the cutover accordingly.

GCS: remove allUsers and allAuthenticatedUsers IAM bindings:

# List current IAM policy
gcloud storage buckets get-iam-policy gs://<bucket-name>

# Remove a specific public binding
gcloud storage buckets remove-iam-policy-binding gs://<bucket-name> \
  --member=allUsers \
  --role=roles/storage.objectViewer

# Enable Uniform Bucket-Level Access to disable per-object ACLs
gcloud storage buckets update gs://<bucket-name> \
  --uniform-bucket-level-access

Removing the allUsers + objectViewer binding above only kills that one grant. Read the full IAM policy and pull every public member (allAuthenticatedUsers and roles like legacyObjectReader), or you'll declare victory while another binding keeps the bucket open.

For all providers, finish by auditing the named IAM users, service accounts, and roles with bucket access. Drop grants for identities that no longer need them and scope what's left to the minimum (read vs. read+write, prefix-scoped where the provider supports it). Then run the same public-bucket sweep across the account so you're not back here next week with a different bucket name.

Step 5: Stand up detection (ongoing)

The fire's out. Now make sure the next one pages you on day zero instead of when a researcher emails you. This is ongoing work, not incident work. It's the part that turns a one-time cleanup into a control.

AWS S3:

  • Enable S3 server access logging on all buckets (Settings → Properties → Server access logging) and send logs to a separate, locked-down logging bucket.
  • Deploy the AWS Config managed rule that flags buckets with public read access. A remediation action can automatically reapply Block Public Access on a violation.
  • Alert on the CloudTrail events that actually open a bucket: PutBucketPolicy, PutBucketAcl, and DeletePublicAccessBlock. Not the read-only GetBucketPolicyStatus, which is noise. Page on any of these that leaves the bucket publicly accessible.

Azure Blob:

  • Enable Azure Monitor diagnostic logs for the storage account (Monitoring → Diagnostic settings → Add diagnostic setting) and send logs to a Log Analytics workspace or storage account.
  • Use Azure Policy to enforce that "Allow Blob anonymous access" is set to Disabled on all storage accounts, with deny effect to prevent non-compliant accounts from being created.
  • Consider Microsoft Defender for Storage, which provides anomaly detection on access patterns including unusual anonymous access spikes.

GCS:

  • Enable Data Access audit logs for Cloud Storage in your Google Cloud project (IAM & Admin → Audit Logs → Cloud Storage → Data Read). By default, data access audit logs are not enabled.
  • Create a log-based alert in Cloud Monitoring that fires when the storage.setIamPermissions event includes allUsers or allAuthenticatedUsers in the member list.
  • Apply the constraints/storage.publicAccessPrevention Organization Policy at the organization or folder level to prevent any bucket in scope from having public access granted.

Step 6: Replace permanent public access with signed/presigned URLs

A permanent public grant is effectively irreversible. Once the URL lands in CDN caches, scrapers, and someone's bookmarks, flipping the bucket private doesn't claw back what already propagated. That's the whole reason signed URLs exist. For anything that legitimately needs external access (CDN origins, file shares, partner transfers), issue time-limited signed URLs instead of standing public access.

AWS S3: presigned URL:

aws s3 presign s3://<bucket>/<object> --expires-in 3600

Azure Blob: SAS token:

az storage blob generate-sas \
  --account-name <account> \
  --container-name <container> \
  --name <blob> \
  --permissions r \
  --expiry <ISO-datetime>

GCS: signed URL:

gcloud storage sign-url gs://<bucket>/<object> \
  --duration=1h \
  --private-key-file=<key.json>

Signed URLs grant scoped, time-limited access to specific objects and do not expose surrounding bucket contents. They expire automatically and can be audited through access logs. They are the correct mechanism for external file sharing in all three major providers.


For indexed exposures by provider, see the provider index, AWS S3 exposure index, Azure Blob exposure index, and GCS exposure index. To check whether a specific bucket is currently accessible, use the open-bucket viewer.