Secure AWS Integrations: The AssumeRole Pattern

When I started building a SaaS product that manages resources across customer AWS accounts, the first decision I had to make was how users would give us access to their AWS accounts.

The obvious (wrong) answer: ask for their AWS access keys, store them in the database, and use them whenever you need to talk to their account.

The right answer is IAM AssumeRole. Once you understand how it works, you won’t consider storing credentials again.

The problem with stored credentials

If you store AWS access keys:

  • You become a high-value target. One breach exposes every customer’s AWS account.
  • Keys don’t expire unless manually rotated. Stale credentials just sit there accumulating risk.
  • You need broad permissions upfront, even if you only use them occasionally.
  • Customers have no visibility into what you’re doing or when.

How AssumeRole works

Instead of sharing credentials, the customer creates an IAM Role in their AWS account with a trust policy that allows your account to assume it. When you need access, you call sts:AssumeRole and get back temporary credentials that expire after a short window. I use 15 minutes.

The flow:

  1. Customer creates an IAM Role with minimal permissions (only what your app needs)
  2. The role’s trust policy references your AWS account ID and an external ID
  3. Your app calls sts:AssumeRole with the role ARN, session name, and external ID
  4. AWS returns temporary credentials (access key, secret key, session token)
  5. You use those credentials for the specific operation, then discard them
public function assumeRole(
    string $roleArn,
    string $sessionName,
    string $externalId,
    int $durationSeconds = 900
): Credentials {
    $result = $this->client->assumeRole([
        'RoleArn' => $roleArn,
        'RoleSessionName' => $sessionName,
        'ExternalId' => $externalId,
        'DurationSeconds' => $durationSeconds,
    ]);

    return new Credentials(
        $result['Credentials']['AccessKeyId'],
        $result['Credentials']['SecretAccessKey'],
        $result['Credentials']['SessionToken']
    );
}

Nothing gets stored. Every operation gets fresh, short-lived credentials.

The External ID: preventing the confused deputy

There’s a subtle attack vector called the confused deputy problem. Without an external ID, a malicious user could give you a role ARN pointing to someone else’s account, and your app would unknowingly assume that role on the attacker’s behalf.

The External ID is a customer-specific identifier assigned by the service provider during account setup. It’s included in both the role’s trust policy and the AssumeRole request. AWS checks that condition alongside the permitted principal. This helps ensure that a service handling multiple customers assumes a role only in the intended customer’s context.

AWS explicitly states that an external ID is not a secret. It complements the trust policy; it isn’t a password or a replacement for restricting which principal can assume the role.

Session names for audit trails

Every AssumeRole call includes a session name. I embed context into it:

$sessionName = "{$appName}-{$action}-{$resourceId}-" . now()->format('Y-m-d-H-i');

This shows up in the customer’s CloudTrail logs, so they can see exactly what operation was performed, on which resource, and when. That transparency costs you nothing extra.

Minimal permissions

The IAM Role only needs permissions for what it’s actually doing. If your app manages EC2 instances, the policy might look like:

{
    "Effect": "Allow",
    "Action": [
        "ec2:DescribeInstances",
        "ec2:StartInstances",
        "ec2:StopInstances"
    ],
    "Resource": "*"
}

No IAM management. No billing access. No S3, Lambda, or anything else. The customer controls exactly what you can do, and they can revoke access instantly by deleting the role.

Why this matters for SaaS

If you’re building any product that touches customer AWS accounts, AssumeRole should be your default:

  • Zero stored credentials, so there is nothing to leak and nothing to rotate
  • Short-lived access, so a 15-minute window limits the blast radius if something goes wrong
  • Customer control, because they own the role, set the permissions, and can revoke at any time
  • A built-in audit trail, since CloudTrail captures every action with the session name you provide
  • Least privilege, with permissions scoped to exactly what’s needed

It takes more setup than storing access keys. The security difference is worth it, and customers who look closely will notice.