Trying Out Policy-Based Routing (PBR), a New Feature of Transit Gateway

Trying Out Policy-Based Routing (PBR), a New Feature of Transit Gateway

On July 30, 2026, Transit Gateway policy-based routing (PBR) became GA, letting packets use different route tables by source, destination, protocol and port. We checked route table association exclusivity, match behavior and CloudFormation support.
2026.07.31

This page has been translated by machine translation. View original

Introduction

On July 30, 2026, policy-based routing (PBR) for AWS Transit Gateway became generally available.

https://aws.amazon.com/about-aws/whats-new/2026/07/aws-transit-gateway-policy-based-routing/

PBR is not a feature that directly specifies the next hop of a packet. It is a feature that selects which route table a packet refers to, based on conditions such as source, destination, protocol, and port. The specification is summarized in Policy table concepts, and how to create entries is covered in Create a policy table entry.

In this article, I checked the relationship with existing route table associations, the behavior and default values of match conditions, the rule evaluation order, how packets that do not match are handled, and the scope of CloudFormation support.

Verification

Test environment

Architecture diagram

I ran the verification in us-east-1, and associated the policy table with the attachment of the source VPC-A (10.0.0.0/16). Depending on the conditions, either the normal or the inspect route table is selected. The inspect route table has a blackhole static route for VPC-B (10.1.0.0/16), so traffic for which inspect is selected is dropped at the TGW.

In the connectivity tests below, Ncat: TIMEOUT. indicates that the traffic did not reach the destination. Receiving Connection refused or the payload (LAB-OK-80) means the traffic reached the destination VPC, that is, normal was selected.

Policy tables are mutually exclusive with route tables

I tried to associate a policy table with an attachment that already has a route table associated.

aws ec2 associate-transit-gateway-policy-table \
  --transit-gateway-policy-table-id tgw-ptb-xxxxxxxxxxxxxxxxx \
  --transit-gateway-attachment-id tgw-attach-xxxxxxxxxxxxxxxxx

This resulted in the following error.

An error occurred (IncorrectState) when calling the AssociateTransitGatewayPolicyTable operation:
Cannot have both PolicyTableAssociation and RouteTableAssociation on the same TransitGateway Attachment:
tgw-attach-xxxxxxxxxxxxxxxxx

A policy table and a route table cannot be associated with the same attachment at the same time. The policy table documentation also states explicitly: An attachment can be associated with either a policy table or a route table, but not both.

Match conditions and default values

There are five match conditions: source CIDR, source port, destination CIDR, destination port, and protocol, and all of them are optional. Of these, four (destination port, source CIDR, source port, and protocol) were confirmed to work as matches in the connectivity tests described later (I did not measure destination CIDR in isolation).

Use create-transit-gateway-policy-table-entry to create an entry.

aws ec2 create-transit-gateway-policy-table-entry \
  --transit-gateway-policy-table-id tgw-ptb-xxxxxxxxxxxxxxxxx \
  --policy-rule-number 100 \
  --policy-rule '{"DestinationCidrBlock":"10.1.0.0/16","Protocol":"6","DestinationPortRange":"80"}' \
  --target-route-table-id tgw-rtb-xxxxxxxxxxxxxxxxx1

When only some fields are specified, such as --policy-rule '{"Protocol":"1"}', the unspecified fields are filled in with * in the response.

Field Value in response
SourceCidrBlock *
SourcePortRange *
DestinationCidrBlock *
DestinationPortRange *
Protocol 1

--policy-rule '{}', with all fields omitted, was also accepted, and it became State: active with all fields set to *.

There is a protocol constraint on port specification. When I specified an actual port value for a protocol other than TCP or UDP, the API returned a validation error.

InvalidParameterValue: SourcePortRange and DestinationPortRange can only be
specified when Protocol is TCP (6) or UDP (17)

Even for protocols other than TCP and UDP, explicitly specifying *, as in {"Protocol":"1","DestinationPortRange":"*"}, succeeds and the entry becomes State: active. The protocols that can be specified can be confirmed from the error message returned when an unsupported value is passed.

Protocol must be one of ICMP (1), TCP (6), UDP (17), GRE (47), or '*'

GRE is also listed as a specifiable value, but I did not verify GRE match behavior this time.

The results below are for a state in which rule 50, which is added later in "Rules are evaluated in numeric order", does not exist.

For the destination port, I tried rules 100 and 110, which route destination 10.1.0.0/16 / TCP port 80 to normal and 443 to inspect. The catch-all with all conditions set to * is rule 200 (normal). The connectivity tests were run from 10.0.1.100 in VPC-A. VPC-C (10.2.0.0/16) in the table is a control destination that does not hit a blackhole even if inspect is selected.

ncat -w 4 -v 10.1.1.100 80
Attempt Matching rule Selected RT Result
A → B:80 (TCP) 100 (dport 80) normal Payload received (listener present)
A → B:443 (TCP) 110 (dport 443) inspect Ncat: TIMEOUT.
A → B:8080 (TCP) 200 (catch-all) normal Connection refused
A → C:80 (TCP) 200 (catch-all) normal Payload received

Even with the same destination and the same protocol, destination port 80 was reachable while 443 was dropped. The instance in VPC-B listens on both 80 and 443, and the security group allows all protocols from the lab CIDR. Only 443 is dropped because rule 110 selects inspect and the traffic hits the blackhole static route.

For the source CIDR, I used three rules whose targets are all inspect. All of them are destination 10.1.0.0/16 / TCP, with destination ports 9011 (rule 51), 9012 (52), and 9013 (53).

Attempt Matching rule (SourceCidrBlock) Result Verdict
A → B:9011 51 (10.0.1.100/32, exact match with the source) Ncat: TIMEOUT. inspect
A → B:9012 200 (10.0.99.0/24 in 52 does not contain the source) Connection refused normal
A → B:9013 53 (10.0.0.0/16, a broad CIDR containing the source) Ncat: TIMEOUT. inspect
A → B:9014 200 (no applicable rule, control) Connection refused normal

Both an exact /32 match and a /16 containing the source matched. A CIDR that does not contain the source fell through to the catch-all.

For the source port, I prepared rule 61 with a single value (source port 44444, destination port 9021) and rule 62 with a range (44000-45000, destination port 9022). Both are destination 10.1.0.0/16 / TCP, with inspect as the target. I fixed the source port with ncat's -p.

ncat -w 4 -v -p 44444 10.1.1.100 9021
Attempt Matching rule Result Verdict
B:9021 with ncat -p 44444 61 (matches the single value) Ncat: TIMEOUT. inspect
B:9021 with automatic source port 200 (does not match 61) Connection refused normal
B:9022 with ncat -p 44500 62 (within the range) Ncat: TIMEOUT. inspect
B:9022 with ncat -p 43000 200 (outside the range of 62) Connection refused normal

Routing by source port worked for both a single value and a range, and even for the same destination port 9021, the selected route table changed depending on whether the source port was 44444.

For the protocol, I created two UDP rules and compared the behavior of TCP and UDP. Both are destination 10.1.0.0/16; rule 71 routes UDP / destination port 9031 to inspect, and rule 72 routes UDP / destination port 9032 to normal. The UDP reachability results were checked on the destination side.

Attempt Matching rule Result Verdict
A → B:9031 (TCP) 200 (UDP rules do not apply) Connection refused normal
A → B:9032 (TCP) 200 (UDP rules do not apply) Connection refused normal
A → B:9031 (UDP, 3 packets) 71 0 packets arrived at B inspect (blackhole)
A → B:9032 (UDP, 3 packets) 72 3 packets arrived at B (response received) normal

Even with the same destination port, TCP did not match the UDP rules and fell through to the catch-all. For UDP, 9031 went to inspect and 9032 went to normal.

Rules are evaluated in numeric order

To the state with rules 100, 110, 120 and the catch-all 200, I added rule 50, a broad rule with destination 10.1.0.0/16 / TCP and dport set to *. The entries that existed in the policy table at measurement time are as follows.

# Condition Target
50 dst 10.1.0.0/16 / TCP / dport * inspect
100 dst 10.1.0.0/16 / TCP / dport 80 normal
110 dst 10.1.0.0/16 / TCP / dport 443 inspect
120 ICMPv4 inspect
200 all * (catch-all) normal
9200 dst 10.1.0.0/16 / TCP / dport 9200 normal

Rule 9200 is an entry created in the CloudFormation verification described later. Because its number is large, it does not affect the evaluation order.

Before adding rule 50, A → B:80 matched rule 100, normal was selected, and the traffic was reachable. After adding rule 50, A → B:80 in a stable state changed to Ncat: TIMEOUT..

The more specific rule 100 (dport 80) was not evaluated, and the broader rule 50 (dport *) with the smaller number was applied first. The policy table concepts page mentioned at the beginning also states explicitly: Rules in a policy table are evaluated in ascending numeric order. It is first-match in numeric order, not longest match, so thinking that "the more specific rule wins" as with route tables is a mistake.

Immediately after changing rules, old and new behavior are mixed, so all verdicts were made in the stable state after convergence.

Packets that do not match are dropped

I deleted the catch-all rule 200 from the policy table and tested connectivity with only rules 100, 110, and 120 remaining.

Attempt Matching rule Result
A → C:80 (TCP) None Ncat: TIMEOUT.
A → B:9003 (TCP) None Ncat: TIMEOUT.
A → B:80 (TCP) 100 Payload received

As when a destination is not found in a route table, packets with no matching entry are dropped (implicit deny).

These drops can be observed from the CloudWatch AWS/TransitGateway namespace as a Sum at 5-minute intervals. These are the results with the TGW dimension.

Period PacketDropCountNoPolicy PacketDropCountBlackhole
Before the test 0 0
During the test 8 3
After the test 0 0

The 8 in PacketDropCountNoPolicy corresponds to the total of the SYNs and their retransmissions sent in the two attempts with no matching rule (C:80 and B:9003). The 3 in PacketDropCountBlackhole recorded in the same period came from the A → B:443 attempt (where inspect is selected) that was run in parallel.

When I also checked at the attachment level, all 8 were counted against the attachment of A, which has the policy table associated, and B and C were 0.

CloudFormation support scope

The following three resource types are available in CloudFormation.

  • AWS::EC2::TransitGatewayPolicyTable
  • AWS::EC2::TransitGatewayPolicyTableAssociation
  • AWS::EC2::TransitGatewayPolicyTableEntry

I confirmed registration in the registry with describe-type.

aws cloudformation describe-type \
  --type RESOURCE \
  --type-name AWS::EC2::TransitGatewayPolicyTableEntry

The result is as follows.

TypeName: AWS::EC2::TransitGatewayPolicyTableEntry
Type: RESOURCE
ProvisioningType: FULLY_MUTABLE
TimeCreated: 2026-05-05T22:06:08.525Z
handlers: create, read, update, delete, list
required: TransitGatewayPolicyTableId, PolicyRuleNumber, PolicyRule, TargetRouteTableId

When I created a stack containing the entry type, rule 9200 reached CREATE_COMPLETE with State: active.

cfn-lint does not recognize the three resource types. With both 1.51.2 and 1.53.0, the following errors occur (as of July 31, 2026).

E3006 Resource type 'AWS::EC2::TransitGatewayPolicyTable' does not exist in 'us-east-1'
E3006 Resource type 'AWS::EC2::TransitGatewayPolicyTableAssociation' does not exist in 'us-east-1'
E3006 Resource type 'AWS::EC2::TransitGatewayPolicyTableEntry' does not exist in 'us-east-1'

While cfn-lint does not recognize the three resource types, registration in the CloudFormation registry and stack creation succeeded. The lint result that "the type does not exist" does not indicate CloudFormation's support status.

Summary

Route branching that could not be expressed by destination alone can now be built with Transit Gateway by itself. Routing such as steering only traffic of specific ports or protocols to a different route table, or sending only some hosts narrowed by source CIDR over a different path, can be expressed with policy table rules.

On the other hand, because a policy table is mutually exclusive with a route table per attachment, it cannot be introduced by adding exceptions to an existing environment. Once you switch, all traffic on that attachment becomes subject to rule evaluation, and traffic that does not match is dropped. The migration work involves identifying existing communication requirements, rewriting them as policy rules, and verifying connectivity after the switch.

References

https://docs.aws.amazon.com/vpc/latest/tgw/tgw-policy-tables-limitations.html

Share this article

AWSのお困り事はクラスメソッドへ