Amazon EKS

Decoupling network ingress: architecting cross-account ALBs for Amazon EKS

Your cluster lives in a locked-down Compute account; your internet-facing ALBs, certs, and DNS live in a monitored Edge account. Here's how a developer deploys a plain Kubernetes Ingress and the platform wires up a secure ALB across the account boundary — without a single static IAM key.

Deep dive·platform & cloud engineers·

Scope & versions. AWS Load Balancer Controller (aws-load-balancer-controller) as documented mid-2026. Cross-account TargetGroupBinding (the iamRoleArnToAssume / assumeRoleExternalId fields) is a recent controller feature — pin to a version that ships it and check the CRD before you rely on it. EKS Pod Identity cross-account role chaining (roleArn + targetRoleArn) and IRSA are both current. Re-verify annotation names against the controller's live annotations reference before shipping.

The design meeting ends the same way every time. Security wants every internet-facing load balancer, every public ACM certificate, every Route53 zone, and every WAF rule to live in one hardened, heavily-monitored account — call it Edge, or DMZ, or Ingress. Nobody deploys application code there; it exists to be the front door. Meanwhile your EKS clusters live in a separate Compute account (often one per team or environment) where developers ship all day and blast radius is contained.

Then a developer opens a pull request with a plain Kubernetes Ingress and asks the obvious question: "How does this get me a public URL?" The default answer — the AWS Load Balancer Controller creates an ALB right next to the cluster — is exactly what your security architecture forbids. The ALB has to land in the other account, the one the cluster can't touch. And the tempting shortcut, baking a long-lived IAM access key for the Edge account into the controller, is the thing that gets you a finding in the next audit. This piece is about the clean way to cross that boundary.

TL;DR

  • Separate edge from compute on purpose. Putting internet-facing ALBs, public certs, WAF, and DNS in one governed account — while clusters live in isolated Compute accounts — is a deliberate control, not an accident. The ingress design has to honor it.
  • Never use static cross-account keys. The secure identity path is EKS Pod Identity role chaining (roleArn in Compute assumes a targetRoleArn in Edge) or cross-account IRSA — the controller holds a short-lived, STS-issued credential scoped to the Edge account, and nothing is stored on disk.
  • Two wiring models, one boundary. Either the controller provisions the ALB in the Edge account (via VPC sharing + a cross-account controller role), or the platform pre-provisions the ALB and the controller only binds targets to it with a cross-account TargetGroupBinding. The second decouples infra governance from app deploys most cleanly.
  • TargetGroupBinding is the decoupling primitive. Its iamRoleArnToAssume + assumeRoleExternalId fields let the controller in the Compute account register pod IPs into a target group that lives in the Edge account — the load balancer's lifecycle is fully owned by the platform, not by the cluster.
  • The pod IP has to be reachable. Cross-account ALBs use target-type: ip, so the Edge ALB must reach the pod's VPC address — via VPC sharing (AWS RAM), VPC peering, or Transit Gateway, with security groups opened for the ALB's traffic.
  • Developers deploy a plain Ingress. Lock the Edge subnets, scheme, and tags into an IngressClassParams the platform owns; the developer's manifest carries only host, path, and service. The account boundary becomes invisible to them.
One idea: the boundary is an identity problem wearing a networking costume. Get the STS chain and the pod-IP reachability right, and a plain Ingress "just works" across accounts.

Why the ALB can't live next to the cluster

Out of the box, the AWS Load Balancer Controller runs inside your cluster, watches for Ingress objects, and calls the ELBv2 and EC2 APIs in its own account to build an ALB in the cluster's VPC. That single-account model is elegant and it is what almost every tutorial shows. It also assumes the thing you're trying to break: that the cluster account is allowed to own internet-facing infrastructure.

In a mature landing zone it isn't. The edge is where the internet meets you, so it gets concentrated controls: centralized WAF rule sets, GuardDuty and flow-log pipelines, a small set of humans with change rights, public ACM certificates issued and rotated under one CA policy, and the authoritative Route53 zones. Scatter internet-facing ALBs across a dozen Compute accounts and you lose all of that — every team account becomes an attack surface with its own inconsistent WAF, its own certs, its own logging gaps. Consolidating the edge is how you keep the front door auditable.

So the controller, running in Compute, needs to act in Edge. There are three ways it could get that permission, and only two are acceptable:

  • Static IAM access keys for the Edge account, mounted as a secret. This works and it is a trap: a long-lived credential for your most sensitive account, sitting in a cluster that developers can exec into. It's the first thing a security review flags and the hardest to rotate. Don't.
  • Cross-account IRSA — the controller's ServiceAccount is federated (via the cluster's OIDC provider registered in the Edge account) to assume a role there. Short-lived STS credentials, no secrets on disk.
  • EKS Pod Identity role chaining — the newer path: a local role in Compute trusts the pods.eks.amazonaws.com principal, and EKS automatically chains an sts:AssumeRole into a target role in Edge. No per-cluster OIDC wiring to maintain.

The rest of this piece builds on the two credential-free options. First the identity chain, then the two ways to wire the actual load balancer, then the developer-facing abstraction that hides all of it.

The credential-free path: Pod Identity role chaining into Edge

Everything hinges on how the controller gets permission to act in the Edge account. The wrong answer is a stored key. The right answer is a short-lived credential minted by STS, and EKS Pod Identity now makes the cross-account version of that almost boring.

With Pod Identity, you associate two roles with the controller's ServiceAccount. The roleArn is a role in the Compute account that trusts the pods.eks.amazonaws.com service principal; the targetRoleArn is a role in the Edge account that trusts the first role. When the controller pod asks for credentials, EKS performs IAM role chaining automatically — it assumes roleArn, then uses that to assume targetRoleArn, and hands the pod the Edge role's temporary credentials. The controller never sees a long-lived secret and never even knows chaining happened.[3]

The neat security detail: EKS injects an sts:ExternalId during the chained assume, derived from the pod's identity — region/account-id/cluster-name/namespace/service-account — and the Edge target role can require it. That's confused-deputy protection you get for free: only this ServiceAccount in this cluster can assume the Edge role, even if another principal learns the ARN.[3]

Here's the wiring. The two trust policies and the association are the whole security story; the Helm install just points the controller at its ServiceAccount and stays out of the credential business entirely.

# Trust policy on the LOCAL role in the Compute account.
# It only trusts the EKS Pod Identity service principal.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "pods.eks.amazonaws.com" },
    "Action": ["sts:AssumeRole", "sts:TagSession"]
  }]
}

# ...plus a permission policy letting it assume the Edge role:
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["sts:AssumeRole", "sts:TagSession"],
    "Resource": "arn:aws:iam::111122223333:role/edge-ingress-role"
  }]
}
# Trust policy on the TARGET role in the Edge account.
# It trusts ONLY the Compute local role, and pins the external ID
# that EKS injects (region/account/cluster/namespace/serviceaccount)
# to defeat the confused-deputy problem.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "AWS": "arn:aws:iam::444455556666:role/lbc-podid-local-role"
    },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": {
        "sts:ExternalId":
          "us-east-1/444455556666/prod-cluster/kube-system/aws-load-balancer-controller"
      }
    }
  }]
}
# The Edge role's PERMISSION policy is the standard LBC policy
# (elasticloadbalancing:*, ec2:Describe*, wafv2:*, etc.) scoped to Edge.
# 1) Associate BOTH roles with the controller's ServiceAccount.
aws eks create-pod-identity-association \
  --cluster-name prod-cluster \
  --namespace kube-system \
  --service-account aws-load-balancer-controller \
  --role-arn        arn:aws:iam::444455556666:role/lbc-podid-local-role \
  --target-role-arn arn:aws:iam::111122223333:role/edge-ingress-role

# 2) Install the controller. Note: NO IAM keys, and with Pod Identity
#    the ServiceAccount needs no eks.amazonaws.com/role-arn annotation.
helm install aws-load-balancer-controller \
  eks/aws-load-balancer-controller -n kube-system \
  --set clusterName=prod-cluster \
  --set region=us-east-1 \
  --set vpcId=vpc-0edgeshared0 \
  --set serviceAccount.create=true \
  --set serviceAccount.name=aws-load-balancer-controller
Prefer IRSA? Skip the association and instead register the Compute cluster's OIDC provider in the Edge account, then let the ServiceAccount assume the Edge role directly via a cross-account OIDC trust. Same STS outcome, more per-cluster wiring to maintain.[4]
Least privilege still matters. The Edge role carries the full controller IAM policy, so scope it hard: restrict it to the Edge VPC and subnets with Condition keys, and keep iam:CreateServiceLinkedRole and WAF associations only if you actually use them. A role that can build any ALB in your most sensitive account is worth a second review.

Two ways to wire the load balancer across the boundary

Identity gets the controller permission to act in Edge. Now the actual load balancer has to appear. There are two legitimate models, and the choice is really about who owns the ALB's lifecycle.

Model A · The controller provisions the ALB in Edge

With the Edge role in hand and Edge subnets shared back into the Compute account via AWS RAM, the controller can create the ALB directly in the Edge account. This is the pattern AWS documents in its cross-account load-balancer post: private subnets from the Edge account are shared to the cluster account, the controller assumes the Edge role, and it builds the ALB in those shared subnets when an Ingress appears.[1] The Ingress simply names the Edge subnets and security groups:

alb.ingress.kubernetes.io/subnets: subnet-0edgepub1,subnet-0edgepub2
alb.ingress.kubernetes.io/security-groups: sg-0edge-alb

It works and it's the shortest path. The tradeoff: the cluster account is still the thing creating and deleting internet-facing infrastructure — the ALB's existence is coupled to a Kubernetes object in Compute. If a bad manifest deletes the Ingress, the ALB in your governed account goes with it. That coupling is exactly what many landing zones want to avoid.

Model B · The platform owns the ALB; the controller only binds targets

The cleaner decoupling uses TargetGroupBinding. The platform team pre-provisions the ALB, listeners, TLS cert, WAF association, and an empty target group in the Edge account — with Terraform, in the Edge account's own pipeline, reviewed by the people who own the edge. The cluster never creates or destroys any of it. All the controller does is keep that target group's membership in sync with the running pods.[5]

TargetGroupBinding is the CRD built for exactly this — it "lets users decouple the creation and deletion of load balancers from the lifecycle of a service."[6] And crucially, recent controller versions made it cross-account: the iamRoleArnToAssume and assumeRoleExternalId fields tell the controller to assume a role in the TargetGroup Owner account (Edge) before it registers or deregisters targets. The docs name the two sides plainly: the Cluster Owner (CO) account where the cluster and controller run, and the TargetGroup Owner (TGO) account where the target group lives.[5]

Here's the cross-account TargetGroupBinding for Model B. It names a target group ARN that lives in the Edge account and the role to assume to touch it — that's the entire cross-account handshake:

apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: checkout-edge-tgb
  namespace: team-checkout
spec:
  serviceRef:
    name: checkout          # the in-cluster Service to expose
    port: 8080
  # Target group ARN lives in the EDGE (TargetGroup Owner) account:
  targetGroupARN: arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/checkout-tg/6a4ecf7bfae473c1
  targetType: ip
  # Cross-account: assume this Edge role before registering targets,
  # pinned by an external ID to stop the confused-deputy problem.
  iamRoleArnToAssume: arn:aws:iam::111122223333:role/tg-management-role
  assumeRoleExternalId: checkout-edge-2f9c
  networking:
    ingress:
      - from:
          - securityGroup:
              groupID: sg-0edge-alb   # the Edge ALB's SG
        ports:
          - port: 8080
            protocol: TCP
Why target-type: ip is mandatory here. Instance targets register the node's port via the node's ENI, which the Edge ALB can't reach across the boundary in the general case. IP targets register the pod's VPC address directly — so the ALB in Edge sends packets straight to 10.0.4.11:8080. That only works if the pod IP is routable from Edge, which is the networking half of the problem below.

The networking half: making the pod IP reachable

None of the identity plumbing matters if the Edge ALB can't put a packet on the pod. With IP targets, the pod's address has to be reachable from the Edge account's network, which means one of three joins:

  • VPC sharing (AWS RAM). The Edge account shares subnets into the Compute account; the cluster's nodes and pods actually run in Edge-owned subnets. Simplest routing story, and the model AWS's cross-account post uses.[1]
  • VPC peering. Separate VPCs with a peering connection and non-overlapping CIDRs; add routes both ways. Fine for one or two account pairs, tedious at fleet scale.
  • Transit Gateway. The enterprise default — a hub the Edge and every Compute VPC attach to, with route tables controlling exactly which flows are allowed. Scales to dozens of accounts without a mesh of peerings.

Whichever you pick, two security groups have to agree: the Edge ALB's SG must allow egress to the pod port, and the pods' SG (or the node/cluster SG, or a SecurityGroupPolicy) must allow ingress from the ALB's SG. The networking.ingress block in the TargetGroupBinding above is how the controller manages that rule for you — reference the ALB's security group by ID and it opens the path.

Checkpoint: at this point a pod can be reached from Edge (networking), and the controller can register it from Compute (identity). Everything left is about handing developers a clean interface so they never see any of it.

Hiding the boundary: a plain Ingress for developers

The whole point of separating edge from compute is governance, and governance dies if every developer has to hand-type Edge subnet IDs, the ALB security group, and the assume-role ARN into their manifests. They'll copy-paste the wrong subnet, or worse, learn the Edge role ARN by heart. The fix is to push all of that into platform-owned objects and leave the developer with an Ingress that carries only their intent.

The lever is IngressClassParams, a controller CRD that lets a cluster admin "enforce settings for a set of Ingresses."[7] You bake the Edge subnets, the internet-facing scheme, and the mandatory tags into an IngressClassParams, bind it to an IngressClass named something friendly like alb-edge, and now any Ingress that references that class inherits the Edge wiring automatically. The developer's manifest gets shorter, not longer.

The platform ships the class and params once. Note the namespaceSelector — it restricts which namespaces are even allowed to use this Edge class, so a random team can't self-serve an internet-facing ALB by guessing the class name:

# Owned by the platform team, shipped to every Compute cluster.
apiVersion: elbv2.k8s.aws/v1beta1
kind: IngressClassParams
metadata:
  name: alb-edge
spec:
  scheme: internet-facing
  ipAddressType: ipv4
  subnets:
    ids:                       # Edge public subnets (shared via RAM)
      - subnet-0edgepub1
      - subnet-0edgepub2
  tags:
    - key: managed-by
      value: platform
    - key: edge-class
      value: "true"
  namespaceSelector:           # only these namespaces may use it
    matchLabels:
      ingress-tier: public
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb-edge
spec:
  controller: eks.amazonaws.com/alb
  parameters:
    apiGroup: elbv2.k8s.aws
    kind: IngressClassParams
    name: alb-edge
# Owned by the app team. Everything account-specific is inherited
# from the alb-edge class -- this is the entire ingress they write.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: checkout
  namespace: team-checkout
  annotations:
    # app-level choices are still fair game; boundary details are not:
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:111122223333:certificate/abc-123
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
spec:
  ingressClassName: alb-edge         # <- inherits Edge subnets/scheme/tags
  rules:
    - host: checkout.shop.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: checkout
                port:
                  number: 8080
Push it further: many platforms drop even the ACM/cert and listen-port annotations into the class or a mutating policy, leaving the developer with nothing but ingressClassName, host, path, and service.

This is the payoff the whole design was for. A developer opens a pull request with a fifteen-line Ingress, a reviewer sees intent and nothing else, and merging it produces a hardened, WAF-fronted, correctly-tagged ALB in an account that developer has never had credentials to. The governance boundary is real and enforced — and completely invisible to the person shipping the feature.

Pitfalls — where cross-account ingress silently breaks

Each of these turns a working design into a broken deploy or a security gap. The first three are the ones that eat an afternoon.
PitfallSymptomFix
Pod IP not routable from EdgeALB targets stay unhealthy; health checks time out even though pods are RunningConfirm VPC sharing / peering / TGW routes both ways and non-overlapping CIDRs; open the pods' SG to the ALB's SG
External ID mismatchController logs AccessDenied on sts:AssumeRole into the Edge roleThe Edge trust policy's sts:ExternalId must exactly match what EKS injects (region/account/cluster/ns/sa) or the assumeRoleExternalId in the TGB
Using target-type: instanceCross-account targets never register or aren't reachableCross-account ALBs need ip targets so the ALB addresses pods directly
Static IAM keys "just to ship it"Works, then fails an audit; painful to rotatePod Identity role chaining or cross-account IRSA — never a mounted access key for the Edge account
Controller version too old for cross-account TGBiamRoleArnToAssume is ignored or rejected by the CRDUpgrade to a controller release that ships the field and re-apply the updated CRDs
Model A: deleted Ingress deletes the Edge ALBA bad manifest tears down internet-facing infra in the governed accountPrefer Model B (TargetGroupBinding) so the platform, not the cluster, owns ALB lifecycle
Edge role too broadCluster can build arbitrary ALBs / edit any zone in your most sensitive accountScope the Edge role with Condition keys to the Edge VPC/subnets and only the actions you use
Developers can self-serve public ALBsAny namespace references alb-edge and gets internet exposureGate the class with namespaceSelector on the IngressClassParams

The short version: get the identity chain credential-free (Pod Identity or IRSA, external ID pinned), make the pod IP reachable from Edge (shared/peered/TGW + security groups), and prefer TargetGroupBinding so the platform owns the load balancer while the cluster owns only which pods are healthy. Then hide the whole boundary behind an IngressClass so developers deploy a plain Ingress and never know it crossed an account line.

References

  1. Expose Amazon EKS pods through cross-account load balancer — AWS Containers blog. aws.amazon.com/blogs/containers/expose-amazon-eks-pods-through-cross-account-load-balancer
  2. Patterns for TargetGroupBinding with AWS Load Balancer Controller — AWS Containers blog. aws.amazon.com/blogs/containers/patterns-for-targetgroupbinding-with-aws-load-balancer-controller
  3. Amazon EKS Pod Identity streamlines cross account access — AWS Containers blog. aws.amazon.com/blogs/containers/amazon-eks-pod-identity-streamlines-cross-account-access
  4. Authenticate to another account with IRSA — Amazon EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/cross-account-access.html
  5. TargetGroupBinding — AWS Load Balancer Controller docs (cross-account iamRoleArnToAssume / assumeRoleExternalId). kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/targetgroupbinding/targetgroupbinding
  6. TargetGroupBinding source doc (cross-account YAML example) — kubernetes-sigs/aws-load-balancer-controller. github.com/kubernetes-sigs/aws-load-balancer-controller/.../targetgroupbinding.md
  7. IngressClass & IngressClassParams — AWS Load Balancer Controller docs. kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/ingress_class
  8. Ingress annotations reference (scheme, subnets, target-type, security-groups, target-group-attributes) — AWS Load Balancer Controller docs. kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/annotations
  9. Access AWS resources using EKS Pod Identity target IAM roles — Amazon EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/pod-id-assign-target-role.html
  10. Support for ALBs in multiple AWS accounts (Issue #3634) — kubernetes-sigs/aws-load-balancer-controller. github.com/kubernetes-sigs/aws-load-balancer-controller/issues/3634
  11. A deeper look at Ingress Sharing and Target Group Binding — AWS Containers blog. aws.amazon.com/blogs/containers/a-deeper-look-at-ingress-sharing-and-target-group-binding
  12. Learn how EKS Pod Identity grants pods access to AWS services — Amazon EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/pod-identities.html