Connecting an AWS account to Amazon SES safely
Any hosted tool that sends through your Amazon SES needs access to your AWS account. Here's what a cross-account IAM role should ask for, and what it shouldn't.
Any hosted tool that sends through your Amazon SES needs access to your AWS account. The safe way to grant it is a cross-account IAM role with an external ID, not an access key pasted into a signup form. A role is temporary, scoped to a specific caller, and revocable by deleting one thing. A key is none of those. It’s one piece of the mechanics behind Amazon SES for email marketing, the rest of it covered end to end there.
Key Takeaways
- An IAM access key never expires on its own. AWS’s own guidance calls an access key an account security risk and recommends temporary credentials from a role instead.
- A cross-account role has two policies: who can assume it (a trust policy) and what it can do once assumed (a permission policy). Only the second one is what people usually mean by “permissions.”
- The external ID in the trust policy exists to stop the confused deputy problem: without it, anyone who learns the role’s ARN can get a vendor to assume it on their behalf.
- A sending integration needs
ses:SendEmailand a handful of setup actions. If a policy also asks for S3, EC2, IAM, or billing access, that’s not needed to send email.- You can inspect what a role actually grants with IAM Access Analyzer, and revoke it instantly by deleting the role or the stack that created it. No rotation, no key hunt.
Why an access key is the wrong shape
An access key is a username and password pair that never expires unless someone deletes it. AWS’s own documentation states this plainly: “IAM users with access keys are an account security risk,” and its guidance on programmatic access recommends using IAM roles to generate temporary credentials “whenever possible,” rather than long-term access keys that “never expire” the way a role’s session does.
Three problems follow from that one fact.
It doesn’t expire. A key generated the day you sign up for a vendor is still valid years later unless somebody remembers to rotate it. Most people don’t.
It can’t be scoped to a caller. A key authenticates whoever holds it. There’s no way to say “this key only works when it’s EmailFig calling” versus “this key works for whoever copies it out of a breached database.”
Revoking it means finding every place it’s stored. Give the same key to two vendors, or paste it into a script years ago and forget, and rotating it breaks all of them at once. A role has no equivalent problem: delete it, or the CloudFormation stack that created it, and every session built from it stops working immediately.
What a cross-account role actually is
A role is an AWS identity with two separate policies attached, and the difference between them is the whole point.
The trust policy says who is allowed to assume the role. For a vendor integration, that’s a Principal naming the vendor’s AWS account, plus (see below) a condition on an external ID. Nobody else, however they got the role’s ARN, can use it without also knowing that value.
The permission policy says what the role can do once assumed. This is the part people usually mean when they say “the permissions a vendor is asking for,” and it’s the part worth reading line by line, because it’s the part that can be scoped tightly or left wide open.
Assuming the role doesn’t hand out a password. It calls the AWS Security Token Service AssumeRole API, which returns temporary credentials with a security token attached. Left at its default, that session lasts one hour; a role’s MaxSessionDuration can raise the ceiling to 12 hours, but never further, and a vendor requesting the shortest span that still works is doing you a favor. When the session expires, whoever holds those credentials has nothing: the vendor’s own AWS credentials are what let it ask for a fresh session next time, and only AWS decides whether to grant it.
What the external ID prevents
Here’s the scenario a trust policy without an external ID leaves open, straight from AWS’s own writeup of the confused deputy problem:
A vendor serves many customers. You give the vendor’s account permission to assume a role in your account, and hand the vendor your role’s ARN. So does everyone else who uses that vendor. Now imagine another customer, or an attacker posing as one, tells the vendor “here’s my role ARN, go do the setup,” but instead gives the vendor your ARN. Nothing about the vendor’s own credentials changes. If the vendor has no way to tell your role from anyone else’s, it will happily assume your role on that other customer’s behalf. AWS calls the vendor a “confused deputy” in that moment: trusted, but tricked into acting on the wrong account.
The external ID fixes this by requiring a value in the trust policy’s Condition block that only the vendor is supposed to know for your specific connection, and that you never generated or chose yourself. When the vendor assumes a role, it includes the external ID that belongs to that customer’s connection. If a role ARN gets attached to the wrong customer’s request, the external ID the vendor sends won’t match what your trust policy expects, and the assume call fails outright.
Two things follow:
- The external ID has to come from the vendor, generated per connection, never typed in by you. A signup form that lets you make up your own value, or leave it blank, is decorative rather than a real defense: AWS’s guidance is explicit that the value must be “controlled by [the vendor], not its customers.”
- The vendor should refuse a role that doesn’t require one. AWS’s own worked example includes deliberately testing whether a role can be assumed without the correct external ID, and walking away if it can. A vendor that skips this check will happily connect an account whose role trusts it a little too loosely, and never tell you.
An external ID isn’t a secret the way a password is; AWS says outright that anyone with permission to view the role can see it. What it protects against isn’t disclosure, it’s misattribution.
What SES actions a sending integration actually needs
This is where “read the policy” turns from advice into something you can actually check line by line. The SES v2 API has well over 100 named actions. A sending integration touches a small slice of them.
| Category | What it needs | What it’s for |
|---|---|---|
| Sending | ses:SendEmail | Send the mail |
| Account status | ses:GetAccount | Read your sandbox status, sending quota, send rate, and whether AWS has paused sending |
| Tenancy and config | ses:CreateTenant, ses:GetTenant, ses:CreateConfigurationSet, ses:CreateConfigurationSetEventDestination, ses:CreateTenantResourceAssociation, and their Get/List/Delete counterparts | Give the account its own SES tenant and configuration set, and route delivery, bounce, and complaint events out of it |
| Domain verification | ses:CreateEmailIdentity, ses:GetEmailIdentity, ses:PutEmailIdentityMailFromAttributes, ses:PutEmailIdentityDkimSigningAttributes | Verify a sending domain and set up its DKIM and MAIL FROM records |
| Event notifications | sns:CreateTopic, sns:SetTopicAttributes on the vendor’s own topic naming pattern | Deliver bounce, complaint, and open/click events back to the vendor |
That’s the whole surface, and every one of those actions is scoped to SES and to SNS topics the vendor’s own naming convention creates, not to arbitrary resources in your account.
What that list conspicuously leaves out matters just as much: nothing touches S3, EC2, Lambda, RDS, or any other data store. Nothing creates IAM users or roles, which would let a vendor grant itself more access later without you noticing. Nothing reads Cost Explorer or your billing console. And nothing can act in a region other than where your sending happens, if the policy conditions every statement on aws:RequestedRegion.
A request for any of those is a request to do something other than send your email, and it’s worth asking the vendor to explain why, in writing, before you approve it.
The policy this platform actually asks for
EmailFig’s BYOS connection (bring-your-own-SES) works exactly the way described above. A CloudFormation quick-create link builds one IAM role and nothing else that outlives the stack. Its trust policy names our AWS account and requires the external ID we generate for your connection, so knowing our account number isn’t enough on its own. Its permissions cover the same ground as the table above and stop there: send, read your account’s sending status, set up the tenant and configuration set, verify a domain, and create the topic that carries bounce and complaint events back to us. Every statement is pinned to a single region, and the notification permissions reach only topics matching our own naming convention.
Nothing in it touches S3, EC2, Lambda, IAM or your billing data. Connect your own Amazon SES account sets out every permission and the reason for it, and connecting your own SES account walks through the setup itself.
The role gets deleted, along with everything it granted, the moment you delete the stack. There’s no separate revoke step and no key to rotate. Once the role is in place, actually leaving the SES sandbox is a separate step, covered in getting out of the SES sandbox.
How to evaluate any vendor’s request
The checklist above works on anyone’s policy, not just this one. Before approving a cross-account role:
- Is it a role, or does the form want an access key? A key request for anything beyond a one-off script is a signal to look elsewhere.
- Does the trust policy require an external ID you didn’t choose yourself? If the field is optional, blank, or self-generated, it isn’t doing its job.
- Is every statement scoped to a region and a resource pattern, or is
Resourcejust*everywhere? A wildcard resource on a sending-only integration is a request for more than sending needs. - Does anything touch IAM, S3, EC2, or billing? None of those are needed to send email through SES.
- What’s the session duration? Shorter is better; a role that insists on the 12-hour maximum for a task that runs in seconds is asking for more standing access than the task needs.
Two AWS tools help you check the answers instead of taking a vendor’s word for them. IAM Access Analyzer finds every role in your account shared with an entity outside it and shows exactly what that entity can do, so you can confirm a role’s real permissions match what the vendor told you. AWS CloudTrail logs every AssumeRole call, so if a role is ever used more than the vendor’s own workflow explains, the log shows it.
Verifying and revoking
Revoking access to a correctly built role is a delete, not a rotation. Delete the role, or the stack that created it, and the vendor’s next AssumeRole call fails immediately. Nothing to hunt down in a script, a .env file, or a teammate’s password manager.
One tradeoff comes with that: deleting the role stops everything at once, including sending that’s currently working, with no equivalent to rotating a key while the old one still runs. For SES sending, that’s the right failure mode: a hard stop you notice beats a quietly overprivileged key nobody remembers granting. AWS connection health errors covers what it looks like on our side when a role is deleted or its trust policy edited out from under a live connection.
If you’re comparing what different hosted SES tools ask for, the mechanics above apply whether you’re looking at ours or a competitor’s. BigMailer and MailBluster both connect to a customer’s own SES account, so it’s worth reading their setup docs against this same checklist before you launch a stack. For the fuller list of tools that connect this way, see the five best email marketing tools for Amazon SES. And if what you actually want to know is what SES itself costs before you connect anything, that breakdown is here; the BYOS and managed prices sit side by side once you’re ready to compare a hosted option against building it yourself.