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 do users 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—and 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 shared secret between you and the customer, generated during account setup. It’s included in both the trust policy and the AssumeRole call. If they don’t match, AWS rejects the request. This ensures your app only accesses accounts that explicitly trust it.

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. Full transparency without extra work on your side.

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—nothing to leak, nothing to rotate
  • Short-lived access—15-minute windows limit the blast radius if something goes wrong
  • Customer control—they own the role, they set the permissions, they can revoke anytime
  • Built-in audit trail—CloudTrail captures every action with the session name you provide
  • Least privilege—permissions are scoped to exactly what’s needed

It takes a bit more setup than just storing access keys. But the security difference is massive—and your customers will appreciate it.