Storafleet connects to a customer's S3 bucket without any access keys. A CloudFormation stack in the customer's own AWS account creates an IAM role that trusts one Storafleet role, only when it presents an ExternalId minted for that one connection, with 37 S3 permissions on a single bucket. Our server assumes that role through STS and gets one-hour credentials that refresh themselves. As of 29 September 2026, the first hop of that chain is proven from production; the second still waits on a run against a real customer-style stack.
The Storafleet case study summarises the handshake. This article is the engineering behind it: the trust policy line by line, why verification tries to break in first, how we arrived at 37 permissions, the bugs we found before AWS was even configured, and exactly what is and is not proven.
Why not just ask for access keys?
Because the first objection a company raises about a storage console is "we are not pasting long-lived AWS keys into a third-party website". Storafleet does accept keys, and stores them encrypted, but a key is still a standing credential held outside the customer's account. If it leaks, it works until someone notices.
A cross-account role inverts that. The role lives in the customer's account and they own it. We store no customer credential at all, only the role's ARN and the connection's ExternalId. Every session we open expires within an hour. Each time we assume the role, the customer's own CloudTrail records it. And the customer can cut us off on their own, at any moment, by deleting the role or the stack.
What does the customer actually run?
The connect wizard asks our API for a setup. The server mints a fresh UUIDv4 ExternalId, stores it in a RoleSetup record that expires after an hour and can be used once, and builds a CloudFormation quick-create link: the customer's own console opens with our template and the ExternalId, our role's ARN, the bucket name and a "create the bucket too?" switch already filled in. Building that link on the server keeps the parameter encoding in one place.
The customer reviews the stack, ticks the box acknowledging that it creates IAM resources (AWS does not let anyone pre-tick it, so the wizard tells them to expect it), creates the stack and pastes the role ARN from its outputs back into the wizard.
The heart of the template is the trust policy:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Ref StorafleetStsRoleArn # one dedicated Storafleet role, not an account root
Action: "sts:AssumeRole"
Condition:
StringEquals:
"sts:ExternalId": !Ref ExternalId # unique to this one connection
The principal is a single dedicated role in our account, not the account's root, so only that role can use the trust, not any identity in our account. The role's maximum session is one hour. If the customer asks the stack to create the bucket, it comes hardened: versioning on, default encryption, all four public-access blocks on, and a retain policy, so deleting the stack can never delete the bucket or its data.
Why does every connection get its own ExternalId?
This is the confused deputy problem. Storafleet is a deputy: one service acting for many customers with one identity. Without an ExternalId, customer A's role trusts "Storafleet". If customer B learns that role's ARN and pastes it into their own workspace, our service would assume A's role on B's behalf, and B would be browsing A's bucket through us.
The ExternalId closes that, provided the customer can never choose it. Ours is minted by our server, stored against the connection and presented by our server. B can paste A's ARN, but our server will present B's ExternalId, A's trust policy will refuse it, and the connection fails.
We considered one ExternalId per workspace and chose one per connection. A single workspace can connect buckets in many different AWS accounts, and one role may back several buckets. With an ID per connection, every bucket is its own independent trust relationship, and nothing in the data model assumes one AWS account per workspace. The ExternalId is returned once, during setup, and never appears in the bucket data the API sends afterwards.
Why does verification try to break in first?
Before anything is saved, the server runs two STS probes against the pasted ARN:
- AssumeRole without the ExternalId must fail. If it succeeds, the trust policy is too open, perhaps hand-edited or a different role that trusts us without the condition, and we refuse the connection with
TRUST_POLICY_TOO_OPEN. - AssumeRole with the ExternalId must succeed. If it does not, the failure is classified:
RCP_BLOCKEDwhen an AWS Organizations policy denies it,EXTERNALID_MISMATCHwhen STS says so, andROLE_NOT_ASSUMABLEotherwise.
The negative probe is the important one. A positive test alone would happily accept a role that anyone holding our identity could assume. Only after both pass does the bucket get saved and the usual head, list, put and delete checks run. If those S3 checks come up short, the connection still saves, with its verified permissions recorded as they really are.
The last error code exists because STS is deliberately vague. A cross-account AssumeRole that fails because the role does not exist, does not trust us or wants a different ExternalId does not always say which, so the message tells the user to check all three.
How do the credentials refresh without touching 30 call sites?
Storafleet had roughly 30 places that build an S3 client from a bucket's getS3Config(). For role connections that method returns an AWS SDK v3 credentials provider instead of static keys. The SDK resolves it lazily and refreshes the session before it expires, so none of those call sites changed.
The chain has two hops:
- Hop 1: a near-zero-permission IAM user in our account, whose only power is
sts:AssumeRole, assumes our dedicatedstorafleet-stsrole. - Hop 2:
storafleet-stsassumes the customer's role, presenting the connection's ExternalId, for 3,600 seconds.
Customer stacks trust the role, not the user, so we can rotate the user's key, or replace it with an instance role later, without asking any customer to re-run a stack. Chained role sessions are capped at one hour by AWS (role chaining), which is also the maximum our template sets.
Two details cost us a bug each. Our first build assumed the customer's role directly as the IAM user, while the template trusts the storafleet-sts role, so every verification would have failed with AccessDenied; an audit of the code against the plan caught it on 12 June 2026, before any AWS infrastructure existed. And the STS session name, storafleet-<owner id>, was once built from a populated database document instead of the ID; it is now sanitised to the characters and 64-character length AWS accepts, a fix that cleared "Failed to list bucket contents" on role buckets. The region is also set explicitly on the provider, and presigned URLs signed with temporary credentials stop working when that session ends, so their lifetime has to fit inside it.
How did we get to 37 permissions?
The first template granted five S3 actions: list, location, get, put and delete. The product calls around 40 distinct S3 operations: lifecycle rules, CORS, versioning, encryption, public-access blocks, tagging, object lock, intelligent tiering, transfer acceleration, multipart uploads and object versions. On a role connection every one of those extra calls would have failed with AccessDenied.
So we stopped guessing. We listed every *Command from @aws-sdk/client-s3 used anywhere in the server, mapped each API call to its IAM action and split them into bucket-level and object-level statements. Some mappings are not obvious: deleting a bucket's lifecycle rules or its CORS settings is authorised by the corresponding Put action, while deleting a bucket policy has its own action. The result is 37 actions, 24 on the bucket and 13 on its objects, on that one bucket only.
Three permissions are deliberately absent. Without s3:CreateBucket and s3:DeleteBucket the role can never create or destroy the customer's bucket. Without s3:BypassGovernanceRetention it cannot override objects under a governance-mode object lock. What it can do is still powerful: change the bucket policy and public-access settings, and delete object versions. Our decision record lists those grants for disclosure to customers rather than leaving them buried in YAML.
What is proven, and what is not?
The feature was built in June 2026 in both the server and the client, including an in-place upgrade from keys to a role that discards the stored keys. Three operational lessons arrived after that.
- Template hosting. We planned to serve the template from a CDN custom domain. CloudFormation's quick-create flow rejected that; it wants the template's direct S3 URL, so templates are published to S3 under versioned, immutable names.
- The account is sticky. Every customer stack has our account ID baked into its trust policy, so moving our STS identity to another AWS account forces every customer to re-run their stack. On 29 September 2026 we rebuilt the identity and re-hosted the template in a new account. No customer connection used the role method yet, so nobody had to re-run anything; with live customers that would have been a support project.
- "Configured" is not "working". The connect dialog decides whether to offer the role method by checking that the settings exist, not that the credential works, so a credential that has stopped working still advertises the feature.
The status, stated plainly: on 29 September 2026, hop 1 was proven locally and from production through the application's own provider code. Our caller identity assumed storafleet-sts, which proves the key pair, the caller's permission policy and the role's trust policy in one call. Hop 2 is not proven. It can only be exercised against a role that trusts storafleet-sts, which means a real stack, and no probe from outside can tell a missing permission on our side from a target that does not trust us. The final proof is the wizard's own CloudFormation flow against a disposable test bucket, connected without keys.
What we'd do again, and what we'd do differently
Again: an ExternalId minted per connection by the server; a negative probe that must fail before anything is saved; a dedicated trust-anchor role so our own credentials can rotate behind it; deriving the permission list from the SDK calls in the code instead of from memory.
Differently:
- Put the STS identity in a dedicated AWS account from day one. Our own runbook recommended it; we used a shared account, and the stickiness above is why it matters.
- Check that the credential works at boot and in health checks, with a call such as
GetCallerIdentity, not just that it is configured. - Read environment changes back with a parser. On the day of the rebuild, our files carried the STS key lines twice, the new values had been pasted over the second copy, and dotenv reads the first.
- Prove hop 2 with a real stack before calling the feature done, and keep the "proven from production" wording for what has been.
If you are building a SaaS product that has to reach into customers' cloud accounts, our SaaS platform development service covers this kind of work end to end.
