Back to articles
Security

IAM Patterns for Amazon SES Template Management

A practical guide to least-privilege IAM for SES templates — starter policies, the limits of IAM-only access control, and what to layer on top.

If you grep for ses:* in any sufficiently old AWS account, you will find at least one IAM role that has it. Usually attached to a deploy role. Usually because somebody, three years ago, was trying to ship a feature and didn't want to argue with security.

That's how transactional email IAM ends up in most B2B SaaS shops. Not by careful design — by the path of least resistance during a Wednesday afternoon merge.

The cost isn't theoretical. ses:UpdateTemplate lets a principal silently rewrite the content of any customer-facing template in the account. There is no diff. There is no review. There is no notification. CloudTrail records the call but not the content. A compromised CI runner with that permission can change every password reset email in your fleet to point at an attacker-controlled domain, and the only way you'd find out is if a customer complained.

Tightening this is not exotic work. It's mostly knowing which IAM actions matter, how they compose, and where IAM stops being the right tool for the job.

The full SES template surface

The actions that touch templates are smaller than you'd expect. Knowing them all is the start of doing this right.

For SES v1 (the original API):

  • ses:CreateTemplate
  • ses:UpdateTemplate
  • ses:DeleteTemplate
  • ses:GetTemplate
  • ses:ListTemplates
  • ses:TestRenderTemplate
  • ses:SendTemplatedEmail
  • ses:SendBulkTemplatedEmail

For SES v2 (the newer, preferred API), the equivalents live under sesv2::

  • sesv2:CreateEmailTemplate
  • sesv2:UpdateEmailTemplate
  • sesv2:DeleteEmailTemplate
  • sesv2:GetEmailTemplate
  • sesv2:ListEmailTemplates
  • sesv2:TestRenderEmailTemplate
  • sesv2:SendEmail (which can use a template)

If your account uses both APIs — most do, transitionally — your IAM policies need to address both surfaces. A policy that only restricts ses:* leaves sesv2:* wide open and vice versa. This is one of the most common gaps I see in audit reviews.

The actual blast radius of UpdateTemplate

The reason UpdateTemplate deserves a separate conversation is that it doesn't behave like other "update" actions. It is destructive without warning.

Consider the sequence:

  1. Engineer A grants ses:UpdateTemplate to a deploy role for shipping templates from CI.
  2. The CI role's credentials are exposed via a misconfigured GitHub Actions runner.
  3. Attacker calls UpdateTemplate against password_reset, replacing the link in the email body with an attacker-controlled domain.
  4. SES happily accepts the update. The template's previous content is gone. CloudTrail records the call but not the body diff.
  5. Customers click the link in their next legitimate password reset email and end up on the attacker's site.

Nothing in that sequence is exotic. The vulnerability is that UpdateTemplate is a content rewrite with the API ergonomics of a safe metadata update. AWS does not warn you. There is no equivalent to AWS Config's "this resource changed" detection that captures the content diff in a usable way.

The blast radius is: every customer who receives a templated email between the moment the bad update lands and the moment somebody catches it.

Least-privilege starter policies

The right factoring is three roles: read, edit, and publish. They map to the way humans actually work — many people might need to read, fewer need to edit, and very few should be able to ship to production.

Read role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:GetTemplate",
        "ses:ListTemplates",
        "ses:TestRenderTemplate",
        "sesv2:GetEmailTemplate",
        "sesv2:ListEmailTemplates",
        "sesv2:TestRenderEmailTemplate"
      ],
      "Resource": "*"
    }
  ]
}

This is what most engineers should have. They can inspect templates, render them, list them. They cannot change anything.

Edit role (non-production).

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:CreateTemplate",
        "ses:UpdateTemplate",
        "ses:DeleteTemplate",
        "sesv2:CreateEmailTemplate",
        "sesv2:UpdateEmailTemplate",
        "sesv2:DeleteEmailTemplate"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestTag/Environment": "staging"
        }
      }
    }
  ]
}

This requires every create/update to be tagged with Environment=staging. Production stays out of reach unless the request is explicitly tagged for it — which it won't be from a staging-scoped role.

The condition only works if your tooling actually applies the tag, which most clients don't by default. The SES API supports Tags on CreateEmailTemplate (v2) but the v1 API doesn't apply tags to templates at all. This is one of the reasons v2 is the right API surface to target if you're starting fresh.

Publish role (production).

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sesv2:CreateEmailTemplate",
        "sesv2:UpdateEmailTemplate",
        "sesv2:DeleteEmailTemplate"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/role": "publisher"
        }
      }
    }
  ]
}

Attached to a small number of principals tagged role=publisher. Combined with an SCP that prevents anyone else in the account from updating production-tagged templates.

Resource-level scoping with template ARNs

SES v2 supports resource-level permissions on email templates. The ARN format is:

arn:aws:ses:<region>:<account-id>:template/<template-name>

This means you can write a policy that scopes editing to specific template name patterns:

{
  "Effect": "Allow",
  "Action": "sesv2:UpdateEmailTemplate",
  "Resource": "arn:aws:ses:eu-west-1:123456789012:template/staging_*"
}

This is a useful belt-and-suspenders complement to tagging. The tag ensures the request is properly labeled. The resource scoping ensures the template name matches a pattern you control. Both are needed because either alone has gaps.

Note that the v1 API has weaker resource scoping. If you're stuck on v1, the tagging condition is your main lever.

SCPs for organization-wide guardrails

Identity-based policies are necessary but not sufficient. An engineer with admin access in a member account can grant themselves whatever they want, unless an SCP at the organization or OU level says otherwise.

The SCPs worth having:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyTemplateChangesOutsideApprovedRoles",
      "Effect": "Deny",
      "Action": [
        "ses:CreateTemplate",
        "ses:UpdateTemplate",
        "ses:DeleteTemplate",
        "sesv2:CreateEmailTemplate",
        "sesv2:UpdateEmailTemplate",
        "sesv2:DeleteEmailTemplate"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::*:role/sovy-template-publisher",
            "arn:aws:iam::*:role/ses-deploy"
          ]
        }
      }
    }
  ]
}

Applied at the production OU. Now no role in the production account can update templates except the two approved ones. A compromised application role, or an engineer with break-glass admin, still can't quietly change a template.

The same SCP can require MFA on the calling session for production template changes:

{
  "Sid": "RequireMFAForProductionTemplateChanges",
  "Effect": "Deny",
  "Action": [
    "ses:UpdateTemplate",
    "sesv2:UpdateEmailTemplate"
  ],
  "Resource": "*",
  "Condition": {
    "BoolIfExists": {
      "aws:MultiFactorAuthPresent": "false"
    }
  }
}

This breaks automated deploys unless they assume a role through an identity that explicitly carries MFA context. That tension is the right one to engineer through deliberately, not the wrong one to dodge.

Where IAM stops being the right tool

IAM is excellent at controlling what AWS principals can call. It is bad at modeling organizational concepts that don't map cleanly to AWS principals.

Consider the structural problems:

The same human, different responsibilities. A senior engineer writes templates and deploys infrastructure. The right answer isn't to give them two AWS users. It's to give them one identity with role-based scoping based on what they're trying to do at the moment. IAM doesn't model "intent" — it models "what credentials are presented." You can fake intent with role assumption and session tags, but it's awkward.

Edit vs publish for the same template. A marketer should be able to draft a copy change. Engineering should approve it. The publish should go through a deploy mechanism. Pure IAM forces you to give the marketer enough access to "draft" — which is at minimum read access — and then orchestrate the actual update through a separate principal that has write access. That orchestration layer is a piece of software you have to build, run, and maintain.

Per-template human ownership. The marketing email is owned by marketing. The password reset is owned by security. These ownerships don't naturally map to IAM principals. You can encode them in tags and resource ARNs, but you're using IAM as a database, which is what tags are warning you against.

Audit beyond CloudTrail. CloudTrail records that a principal called UpdateTemplate. It doesn't record the content. It doesn't record the human behind the principal. It doesn't record the reason. For real change-control audit, you need a layer above CloudTrail that captures these things and links them.

When you find yourself implementing these capabilities — the orchestration layer, the human-to-principal mapping, the content-level audit, the role-based draft/approve/publish flow — you are building a control plane for SES. Some teams build it themselves. Some adopt one.

Sovy is one example of the latter. It maps roles to humans, implements draft/review/publish as first-class workflows, and writes content-level audit logs at every step. Sovy interacts with SES through a single, narrowly-scoped IAM role — the one IAM role you'd write for "the thing that publishes templates" — and exposes the human-facing workflows above it. From an IAM perspective, this is a simplification: one well-bounded principal with a clear set of permissions, instead of a sprawl of personal AWS users with edit access.

A copy-paste minimum-viable policy

If you take nothing else from this post, take this. For a single shared production AWS account, attach the following to a ses-deploy role used exclusively by your CI publishing job:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowTemplateLifecycle",
      "Effect": "Allow",
      "Action": [
        "sesv2:CreateEmailTemplate",
        "sesv2:UpdateEmailTemplate",
        "sesv2:GetEmailTemplate",
        "sesv2:ListEmailTemplates",
        "sesv2:DeleteEmailTemplate"
      ],
      "Resource": "arn:aws:ses:*:123456789012:template/*"
    },
    {
      "Sid": "AllowTestRender",
      "Effect": "Allow",
      "Action": "sesv2:TestRenderEmailTemplate",
      "Resource": "*"
    }
  ]
}

Then write an SCP at the production OU that denies all SES template lifecycle actions to any principal that isn't this role or a small list of break-glass administrators.

For the application principals that actually send email, grant only sesv2:SendEmail and the configuration-set actions you use. Application code should never have UpdateTemplate.

That's the minimum-viable separation. Attach it. Audit which principals currently have ses:* or the v2 equivalents. Remove the ones that don't need them. The amount of risk you eliminate in an afternoon of this work is disproportionate.

Where to invest first

If you're rebuilding from scratch: start on SES v2, write the three-role policy from this post, layer SCPs on top, and route every human-facing template change through a tool that maps people to roles rather than people to AWS principals.

If you're fixing an existing setup: start with an audit. Use CloudTrail Lake or Athena over CloudTrail to find every principal that has called UpdateTemplate or UpdateEmailTemplate in the last 90 days. That is your real attack surface. Anything on that list that isn't a deliberate publishing role is a finding. Most teams find at least one role in that list that surprises them.

The goal is the unsexy one: make UpdateTemplate a deliberate action by a deliberate principal, every time. The mechanism — tagging, SCPs, role separation, an external control plane — matters less than the outcome.


Sovy is a control layer for Amazon SES templates. It maps draft, review, and publish to human roles instead of IAM principals, while interacting with SES through a single narrowly-scoped role. If your IAM strategy for SES is held together with comments, we'd like to hear from you.