# Give a service S3 access without an access key

Canonical: https://rendobar.com/blog/s3-access-without-access-keys/
Author: Abdelrahman Essawy
Published: 2026-09-11
Updated: 2026-09-13

---

## Key takeaways

- An access key stays valid until someone rotates it, so a vendor holding one holds a standing credential to your bucket. AWS's own best practice is to require workloads to use temporary credentials with IAM roles.
- A role in your account can trust a service's signed identity token instead of a key. The service trades the token for credentials that last between 15 minutes and 12 hours, and the trust policy names exactly which token it accepts.
- Scope lives in the role's permission policy. The one here reads, writes and lists a single bucket and has no s3:DeleteObject, so no session issued from it can delete a file.
- On our staging test, against a real AWS account, the connection finished 32 seconds after the CloudFormation stack started, a delivered file was seen in the bucket 12 seconds after its job completed, and deleting the stack removed the connection 7 seconds later.
- Revoking is deleting the role or its stack. There is no key to rotate and no secret of yours left in the vendor's database.

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.

_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 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:

```json
{
  "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:

```json
{
  "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 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:

_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 dialog can be closed. The connection finishes when the stack reports._

_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.

| 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:

```ts
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](/blog/s3-presigned-url-expires-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](/docs/storage/amazon-s3) 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](/blog/video-api-output-to-your-bucket/).
For the rest of the S3 workflow, see [running FFmpeg on files in Amazon S3](/ffmpeg/s3/)
and [saving FFmpeg output to an S3 bucket](/blog/ffmpeg-output-to-s3/).
