The credential you never created can't leak
Most guidance for reaching object storage from a serverless function looks the same: create an access key pair, store it as an encrypted environment secret, sign S3 requests with it. Every step is correct. The whole thing is still worse than necessary.
Because you have now created a long-lived credential that grants bucket access, and it lives somewhere your code can read it. Encrypting it at rest doesn't change that — at runtime it's a string in your process, and anything that achieves code execution or output injection can read it out.
The alternative: don't have one
Several platforms let you attach storage to a function as a binding rather than an endpoint. Instead of an HTTP client pointed at a URL and signed with keys, you get an object in your runtime:
// no endpoint, no region, no access key, no signature
const obj = await env.BLOBS.get("reports/q3.json");
await env.ARCHIVE.put("claude/notes.md", body);
Authorization happens at the platform layer — the binding is declared in configuration and enforced by the infrastructure. Traffic goes over internal networking rather than the public internet. There is no key material in the function, so there is nothing for a compromise to steal, exfiltrate, or accidentally log.
Why this beats "store the key properly"
Compare the two failure modes honestly.
With S3-style keys: an attacker who achieves code execution, or who finds a path that echoes environment state into output, walks away with a credential valid against your bucket from anywhere on the internet, until you notice and rotate. The credential outlives the request, the process, and often your awareness of the breach.
With bindings: the same attacker can call the same operations while inside your function, and gets nothing portable. There is no artifact to carry away. The moment the request ends, so does the access.
That's a meaningful reduction in blast radius, and it costs less code than doing it the other way.
What you give up
Be honest about the constraint: bindings only work on the platform that provides them. You are trading portability for the removal of a secret.
If you might move this workload to another provider next quarter, the S3 API is a genuine lingua franca and worth the key management. If the function is already platform-specific — it runs at that edge, using that runtime, alongside that storage — then the portability you're "preserving" is theoretical, and you're paying for it with a real credential.
Pair it with server-side containment
Removing the credential is necessary, not sufficient. Whatever can call your function can still do whatever your function permits. So constrain the operations themselves:
- Force writes under a single prefix
- Refuse deletes outside that prefix
- Never accept a caller-supplied full path without validation
Then verify by test. Attempt a delete outside the allowed prefix and confirm it's refused. "I intended it to be contained" and "I confirmed it is contained" are different claims, and only one of them belongs in a security discussion.
The general shape
The strongest thing you can do with a secret is not need one. Before designing careful handling for a credential, check whether the platform offers an identity-based or binding-based path that removes it from the picture — managed identities, workload identity federation, native bindings, IAM roles attached to compute.
Careful secret handling is the fallback. Structural elimination is the goal.