Trying Out Policy-Based Routing (PBR), a New Feature of Transit Gateway
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.
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

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::TransitGatewayPolicyTableAWS::EC2::TransitGatewayPolicyTableAssociationAWS::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
