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.

Share

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.

THE SERVICEidentity tokensigned, one subjectper connectionstores no keyasks per deliverynarrows each sessionAWS STSAssumeRoleWithWebIdentity15 min to 12 htemporarycredentialsYOUR AWS ACCOUNTIAM role, trust policyaud = sts.amazonaws.comsub = this connectioncheckspermissionss3:PutObject on one bucketS3 bucketNothing long lived crosses the boundary. A leaked session dies within hours, a leaked key never does.Revoke by deleting the role or its stack: the next AssumeRole call fails and no delivery can start.The subject condition is what stops one customer's connection from reaching another customer's role.
Nothing long lived crosses from your account to the service. The service proves who it is, and AWS hands it a short session for one bucket.

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 keyRole with an external IDRole trusting an identity token
What the service holdsYour key ID and secretIts own AWS credentialsA key that signs its own tokens
Credential lifetimeUntil rotated15 minutes to 12 hours per session15 minutes to 12 hours per session
What stops another customer using itThe key is yours aloneThe external ID in your trust policyThe subject condition in your trust policy
What you createA user, a policy, a keyA role trusting the vendor’s accountAn identity provider and a role
How you revokeDeactivate the keyDelete the roleDelete 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 Amazon S3 connect dialog with One-click role selected. The bucket rendobar-test-1 was found in us-east-1, access is set to Deliver and read with the note Writes job outputs here and reads them back, no delete, ever, and the button reads Create the Role in AWS.
The dialog checks the bucket exists and where it lives before opening AWS.

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:

The AWS CloudFormation Quick create stack page. The template URL points at a public template, the stack description says it lets Rendobar deliver job outputs to one S3 bucket through a role it assumes with its own OpenID identity, that no access keys are created, and that deleting the stack revokes access.
Nothing is created until you acknowledge the IAM resources and choose Create stack.

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

The connect dialog's progress view with three steps. Prepared, stack opened in AWS, is done. Running in AWS, creating the role, usually under two minutes, is in progress. Connected, tested and saved, is next.
The dialog can be closed. The connection finishes when the stack reports.
The finished connection test-1 for bucket rendobar-test-1 on Amazon S3. Write, Read and List passed, and Delete is shown as not performed. Below are the storage references to use in jobs and the output path pattern.
The connection tests write, read and list before it is saved. Delete is skipped because the role has no delete permission.

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.

StepResult
Prepare the connection2.1 s
Stack started to connection saved32 s
Access checkswrite passed, read passed, list passed, delete skipped
Deliver a 352,121-byte job outputseen in the bucket 12 s after the job completed
Read that file back as a job inputjob completed
Stack deleted to connection removed7 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

Tags #s3#aws-iam#security#storage#cloudformation
All posts
Share
  1. Video APIs that deliver output to your own bucket Guides for the video API
  2. Add video processing to n8n Cloud Guides for the video API
  3. Why S3 presigned URLs expire early Guides for the video API