Give a service S3 access without an access key
Send job outputs to an S3 bucket through an IAM role instead of an access key. In our test the role connected 32 seconds after its stack started.
Almost every service that writes into a customer’s S3 bucket asks for the same thing first: an access key ID and a secret, pasted into its dashboard. It works, and it leaves a permanent credential to your bucket sitting in someone else’s database.
Short version. Give the service an IAM role instead. The role lives in your account, trusts only the service’s identity, and allows only the bucket and actions you name. The service gets credentials that expire within hours, and you revoke it by deleting the role. We built this path into Rendobar’s Amazon S3 connection and measured it end to end against a real AWS account: connected in 32 seconds, a delivered file in the bucket 12 seconds after its job, and access removed 7 seconds after the stack was deleted.
What is wrong with handing over an access key
An access key belongs to an IAM user and stays valid until someone deactivates or rotates it. AWS’s own guidance puts it plainly. The IAM security best practices open with “require workloads to use temporary credentials with IAM roles to access AWS”, and treat access keys as the exception for “use cases that require long-term credentials”.
A vendor integration is exactly the case that doesn’t need one. The practical problem is not that the vendor is careless. It is that a key has no natural end. Once pasted, it keeps working after the integration is abandoned, after the person who created it leaves, and after a breach on the other side that you may never hear about.
Three ways to grant a service access
| IAM user access key | Role with an external ID | Role trusting an identity token | |
|---|---|---|---|
| What the service holds | Your key ID and secret | Its own AWS credentials | A key that signs its own tokens |
| Credential lifetime | Until rotated | 15 minutes to 12 hours per session | 15 minutes to 12 hours per session |
| What stops another customer using it | The key is yours alone | The external ID in your trust policy | The subject condition in your trust policy |
| What you create | A user, a policy, a key | A role trusting the vendor’s account | An identity provider and a role |
| How you revoke | Deactivate the key | Delete the role | Delete the role |
The middle column is the long-standing pattern. The role trusts the vendor’s AWS account, and a value the vendor generates for each customer, the external ID, closes what AWS calls the confused deputy problem: without it, another customer who learns your role’s ARN could ask the vendor to act on your bucket.
The right column is the newer one, and it is what Rendobar uses. The role trusts a token issuer rather than an AWS account, so there is no account relationship to manage and no shared identifier to leak.
The trust policy, line by line
This is the shape of the trust policy the stack creates, with the issuer and subject replaced by placeholders:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/issuer.example.com/federation" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "issuer.example.com/federation:aud": "rendobar-storage", "issuer.example.com/federation:sub": "YOUR_CONNECTION_SUBJECT" }, "Bool": { "sts:RoleAuthorizedByIdp": "true" } } } ]}The principal is an OIDC identity provider registered in your account. AWS fetches the issuer’s public keys to check each token’s signature.
The audience has to match the value the issuer puts in tokens meant for this purpose, so a token minted for something else can’t be replayed here.
The subject is the line that matters most. Every customer’s role trusts the same issuer, so the subject is what ties this role to your one connection and nobody else’s.
sts:RoleAuthorizedByIdp adds a second lock. AWS describes it as verifying “that the identity provider (IdP) explicitly authorized the requested role through the https://aws.amazon.com/roles claim in the OIDC token”. The token has to name your role, not just carry the right subject.
The permission policy: one bucket, no delete
Trust decides who may assume the role. The permission policy decides what that session can do:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::my-bucket/*" }, { "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::my-bucket" }, { "Effect": "Allow", "Action": ["s3:ListAllMyBuckets", "s3:GetBucketLocation"], "Resource": "*" }, { "Effect": "Allow", "Action": ["s3:PutObject", "s3:AbortMultipartUpload"], "Resource": "arn:aws:s3:::my-bucket/*" } ]}Reading and writing objects stays inside one bucket. ListAllMyBuckets and GetBucketLocation let the dashboard offer the account’s other buckets by name and region, without reading their contents. AbortMultipartUpload lets the service clean up its own half-finished large uploads.
What’s missing is the point. There is no s3:DeleteObject, so no session issued from this role can remove a file. A read only connection drops the last statement entirely, and the service can then read and list but never write.
A session can be narrowed further than its role at the moment it is issued, and never widened. Each delivery asks for credentials limited to the one object it is about to write, so even a session that leaked mid-delivery could touch only that key.
One click instead of a runbook
Writing that role by hand is a runbook: register the identity provider, paste two policies, set the maximum session duration, copy the role ARN back. A CloudFormation quick-create link turns it into a review screen. In AWS’s words, it lets you “prepopulate a single Create stack page” with the template URL, stack name and parameters.
Picking One-click role in the connect dialog builds that link for one bucket:

The AWS console then opens on the stack, with the template description spelling out what it creates. You can expand the template and read every line before anything exists:

While the stack runs, the dialog waits for it to report back, and the connection completes on its own:


Measured: connect, deliver, read back, revoke
To put numbers on it without a person clicking through the console, a script created the same stack with the CLI from the same template and parameters the link carries, then used the connection and deleted it. It ran against our staging environment and a real AWS account in eu-west-1.
| Step | Result |
|---|---|
| Prepare the connection | 2.1 s |
| Stack started to connection saved | 32 s |
| Access checks | write passed, read passed, list passed, delete skipped |
| Deliver a 352,121-byte job output | seen in the bucket 12 s after the job completed |
| Read that file back as a job input | job completed |
| Stack deleted to connection removed | 7 s |
The connection finished before CloudFormation had marked the stack complete. The stack reports to Rendobar from inside itself once the role exists, and the remaining bookkeeping on the AWS side doesn’t hold anything up. The 12-second delivery figure was observed by polling every 2 seconds, so read it as a ceiling on that one small file rather than a delivery speed.
Building this into your own service
If you run a service that writes into customers’ buckets, the pattern transfers directly. You need an OIDC issuer that serves its public keys, a subject per customer connection, and a template your customers can launch. At delivery time you exchange a token for a session, and you narrow that session to the one object you are writing:
import { STSClient, AssumeRoleWithWebIdentityCommand } from "@aws-sdk/client-sts";
const sts = new STSClient({ region: "us-east-1" });
const { Credentials } = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: connection.roleArn, RoleSessionName: `deliver-${jobId}`, // Your issuer signs this with the customer's connection as the subject. WebIdentityToken: await signToken({ sub: connection.subject, aud: "your-audience" }), DurationSeconds: 900, // A session policy can only narrow the role. This session can write one key. Policy: JSON.stringify({ Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: "s3:PutObject", Resource: `arn:aws:s3:::${bucket}/${key}` }], }), }),);Two details cost us real time. An identity provider outlives the stack that created it, which is deliberate so deleting one connection can’t break another, so a second connection in the same AWS account must not try to create it again. And a presigned URL signed with these credentials dies when the session does, whatever expiry it asks for, which is its own trap: why S3 presigned URLs expire early measures it.
Where this stops
The measurements come from our staging environment, one run, one small file, one region. They show the sequence works and roughly how long each step takes. They are not a benchmark of CloudFormation or of delivery speed.
The role can’t delete, but anything allowed s3:PutObject can write a new object over an existing key. Rendobar’s connections keep both files on a name clash by default, and a connection set to replace is doing exactly what it says.
Deleting the role stops new sessions. A session already issued keeps working until it expires, which is why the lifetime matters, and AWS’s Revoke active sessions action exists for cutting one off early.
A role is an AWS concept. Cloudflare R2, Supabase Storage and other S3-compatible services connect with a scoped token, a sign-in or a key pair instead.
The Amazon S3 storage guide has the steps for connecting a bucket this way.
For how 13 video processing services get into your bucket, keys or roles, see video APIs that deliver output to your own bucket. For the rest of the S3 workflow, see running FFmpeg on files in Amazon S3 and saving FFmpeg output to an S3 bucket.
Frequently asked questions
How do I give a third party access to my S3 bucket without sharing an access key?
Create an IAM role in your account whose trust policy names the third party, either their AWS account with an external ID or their identity provider with a subject condition, and whose permission policy allows only the bucket and actions they need. They assume the role and receive temporary credentials instead of holding a key.
What is the difference between an external ID and OIDC federation for cross-account access?
With an external ID the role trusts the vendor's AWS account, and a value the vendor generates for you stops another customer from pointing the vendor at your role. With OIDC federation the role trusts a token issuer and checks the token's audience and subject, so the vendor needs no AWS account relationship with you at all.
What permissions does a service need to write files to one S3 bucket?
s3:PutObject on the bucket's objects, plus s3:AbortMultipartUpload if it uploads large files in parts. It needs s3:GetObject and s3:ListBucket only if it also reads. None of that requires s3:DeleteObject or access to any other bucket.
How do I revoke a service's access to my S3 bucket?
Delete the role, or the CloudFormation stack that created it, and the service can no longer assume it. Credentials it already received expire on their own schedule, and AWS's Revoke active sessions action cuts those off early by attaching a deny policy for sessions issued before that moment.
Is it safe to open a CloudFormation quick-create link from a vendor?
The link only opens the console with a template and parameters filled in. You can read the template before anything happens, the console asks you to acknowledge that it creates IAM resources, and nothing is created until you choose Create stack.
Sources
- AWS: security best practices in IAM
- AWS: the confused deputy problem
- AWS: create a role for OpenID Connect federation
- AWS: IAM and STS condition keys, including sts:RoleAuthorizedByIdp
- AWS: methods to assume a role and session durations
- AWS: revoke IAM role temporary security credentials
- AWS: CloudFormation quick-create links
