Client VPN hands the client an address, the handshake completes, and the connection still goes nowhere. Before you touch certificates or client versions, look at the endpoint's own route table. An AWS Client VPN endpoint forwards a packet only when one of its routes covers the destination prefix; if nothing matches, the packet is discarded at the endpoint and the target host never sees a SYN.

That is a forwarding decision, not an authentication one. It happens after the tunnel is up and after the authorization rules are evaluated, which is why a valid certificate and a valid group can still produce a dead connection.

The endpoint forwards only what its route table covers

Each route is a destination CIDR plus the subnet that should receive the traffic. The endpoint checks the client's destination against that list, picks the matching entry, and sends the packet into the associated subnet. No entry means no next hop.

Note what the route is not. It is not a VPC route table entry, and it does not authorize anything. Authorization rules decide whether a group may reach a destination; routes decide whether the endpoint has somewhere to send the packet. Both must agree, and they fail with different symptoms, so guessing which one is broken wastes an afternoon.

The classic mismatch is a peered VPC. Someone adds a route for 10.20.0.0/24 while the host lives in 10.20.4.0/24, so the address falls outside every entry. A client-side capture shows repeated SYNs with no reply, and the target's logs show nothing at all.

Read the route list before the certificate

The endpoint's routes are their own object, so query them directly instead of reading the VPC table:

aws ec2 describe-client-vpn-routes --client-vpn-endpoint-id cvpn-endpoint-0123456789abcdef0 --query 'Routes[].{Dest:DestinationCidr,Target:TargetSubnet,Origin:Origin}'

Take the exact address and prefix length from the failing client, then check coverage character by character. Longest-prefix match applies, so a broader route does not save you when a narrower one points somewhere else.

One resource per destination with for_each

Hand-written route blocks drift the moment someone adds a prefix in the console. Keep the destinations in a map and create one resource per key, so each addition or removal stays isolated.

variable "client_vpn_routes" {
  type = map(string)
  default = {
    "10.20.0.0/16"  = "subnet-0aa11bb22cc33dd44"
    "172.31.0.0/20" = "subnet-0aa11bb22cc33dd44"
  }
}

resource "aws_ec2_client_vpn_route" "this" {
  for_each               = var.client_vpn_routes
  client_vpn_endpoint_id = var.client_vpn_endpoint_id
  destination_cidr_block = each.key
  target_vpc_subnet_id   = each.value
  depends_on             = [aws_ec2_client_vpn_network_association.this]
}

Keep the dependency on the network association. A route needs an associated subnet, so an apply that creates both has to order the association first, and without that reference Terraform is free to try the route first. Then read the plan: the number of route additions should match the prefixes you added. If the plan also replaces the endpoint, the endpoint resource is written wrong, and applying it disconnects every connected client.

Adopting an endpoint you did not create

Most teams inherit an endpoint built in the console, which is why no route code exists. Do not stand up a second endpoint just to get routes into Terraform.

Import instead, or pass the ids in as variables and leave the inherited objects unmanaged. Import needs the identifier format the provider documents for each resource type, which encodes the endpoint, the destination prefix and the target subnet. Copy that string from the resource documentation rather than composing it from memory, run a plan, and expect no changes before you add anything new.

Trace the packet in the order it travels

When the route exists and the connection still fails, walk the path in order rather than guessing at the top layer.

  1. Route: a prefix covers the destination and points at a subnet associated with the endpoint.
  2. Authorization: an authorization rule allows that destination for the user's group. A route alone never grants access.
  3. Return path: the target subnet's route table sends replies back toward the endpoint, and the security group allows the port from the client address pool.
  4. Application: open a real TCP connection from the client, then confirm the target logs show the expected source address from the pool.

Change one prefix per apply, re-read the route list afterwards, and record the destination, the authorizing rule and the return path together. The next person debugging a refused connection should start from that inventory, not from a certificate theory.

ບົດຄວາມທີ່ກ່ຽວຂ້ອງ