Connecting GitHub Codespaces to AWS VPN via SPIFFE/SPIRE & IAM Roles Anywhere

Preamble

In Grant AWS Access to GitHub Codespaces via SPIFFE/SPIRE & IAM Roles Anywhere, we setup a SPIRE Server and SPIRE Agent and successfully authenticated to AWS using a SPIRE issued certificate.

In this post, we'll be building on that work, with the goal of using the same certificate to authenticate to AWS Client VPN.

Prerequisites

  • All the setup in the previous post
  • AWS resource that we'll be connecting to via the VPN, e.g. an EC2 instance

Setup the VPN Server Certificate

This builds off the PKI we built in Setting up our Public Key Infrastructure. We create a certificate signing request for our vpn server and have it signed by our Root CA. Then we add said certificate to AWS ACM so that we can reference it later when we setup our AWS Cient VPN endpoint.

# 3. VPN Server Private Key
resource "tls_private_key" "vpn_server_key" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

# 4. VPN Server Certificate Signing Request (CSR)
resource "tls_cert_request" "vpn_server_csr" {
  private_key_pem = tls_private_key.vpn_server_key.private_key_pem

  subject {
    common_name  = "vpn.misaac.me"
    organization = "Misaac Org"
    country      = "US"
  }

  dns_names = ["vpn.misaac.me"]
}

# 5. Root CA Signs VPN Server Certificate (leaf cert)
resource "tls_locally_signed_cert" "vpn_server_cert" {
  cert_request_pem   = tls_cert_request.vpn_server_csr.cert_request_pem
  ca_private_key_pem = tls_private_key.root_ca_key.private_key_pem
  ca_cert_pem        = tls_self_signed_cert.root_ca_cert.cert_pem

  validity_period_hours = 8760 # 1 year

  allowed_uses = [
    "server_auth",
    "client_auth",
    "digital_signature",
    "key_encipherment",
  ]

  is_ca_certificate = false # It's a leaf cert (not a CA)
}

# 6. Upload VPN Server Certificate (and chain) to ACM
resource "aws_acm_certificate" "vpn_server_cert" {
  private_key       = tls_private_key.vpn_server_key.private_key_pem
  certificate_body  = tls_locally_signed_cert.vpn_server_cert.cert_pem
  certificate_chain = tls_self_signed_cert.root_ca_cert.cert_pem
}

Setup our Client Certificate (SVID)

As we have the SPIRE agent setup from our work in the previous post, all we need to do is request a certificate from it.

$ bin/spire-agent api fetch x509 -write /tmp/          
Received 1 svid after 254.053709ms

SPIFFE ID:              spiffe://misaac.me/myservice
SVID Valid After:       2025-05-19 11:06:54 +0000 UTC
SVID Valid Until:       2025-05-19 12:07:04 +0000 UTC
Intermediate #1 Valid After:    2025-05-18 21:04:32 +0000 UTC
Intermediate #1 Valid Until:    2025-05-19 21:04:42 +0000 UTC
CA #1 Valid After:      2025-04-29 01:29:07 +0000 UTC
CA #1 Valid Until:      2035-04-27 01:29:07 +0000 UTC

Writing SVID #0 to file /tmp/svid.0.pem.
Writing key #0 to file /tmp/svid.0.key.
Writing bundle #0 to file /tmp/bundle.0.pem.


Setting up Client VPN

To setup an AWS Client VPN endpoint, we need to :

  • Set up logging
  • Add a security group rule to allow access to your target resource
  • Create the VPN endpoint
  • Associate a target network
  • Add an authorization rule

Setting up logging

Connection logging records client connection requests, outcomes (success or failure), failure reasons, and client termination time. Since we care about security, this observability is something we want to have.

resource "aws_cloudwatch_log_group" "client_vpn" {
name = "aws-client-vpn-logs"
}

resource "aws_cloudwatch_log_stream" "client_vpn" {
name = "aws-client-vpn"
log_group_name = aws_cloudwatch_log_group.client_vpn.name
}

Add a security group rule to allow access to your target resource

If you create an AWS Client VPN endpoint without specifiying a security group, the VPC's default security group is automatically applied to it.

AWS strongly recommends that default security groups restrict all inbound and outbound traffic, even though they cannot be deleted. This is because inadvertently assigning a new AWS resource to the default security group can lead to unauthorized access if it has open rules.

If your are following this best practice (which you should), you'll need to create a security group/security group rule to allow access from the VPN to your target resource. Adjust the ingress rules (port, protocol) based on the resource you need to access (e.g., port 5432 for PostgreSQL).

resource "aws_security_group" "allow_ssh" {
name = "allow_ssh"
description = "Allow SSH inbound traffic and all outbound traffic"
vpc_id = aws_vpc.main.id

tags = {
Name = "allow_ssh"
}
}

resource "aws_vpc_security_group_ingress_rule" "allow_ssh_ipv4" {
security_group_id = aws_security_group.allow_ssh.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = 22
ip_protocol = "tcp"
to_port = 22
}
resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv4" {
security_group_id = aws_security_group.allow_ssh.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # semantically equivalent to all ports
}

Create the Client VPN Endpoint

This is the central resource you create and manage to enable secure connections between your remote users and your AWS resources. It acts as the termination point for all client VPN sessions.

Note: Take care to select a cidr range for your vpn endpoint that does not clash with the cidr range of your VPC.

resource "aws_ec2_client_vpn_endpoint" "org" {
description = "misaac.me VPN"
server_certificate_arn = aws_acm_certificate.vpncert.arn
client_cidr_block = "172.16.0.0/16"

authentication_options {
type = "certificate-authentication"
root_certificate_chain_arn = aws_acm_certificate.vpncert.arn
}

connection_log_options {
enabled = true
cloudwatch_log_group = aws_cloudwatch_log_group.client_vpn.name
cloudwatch_log_stream = aws_cloudwatch_log_stream.client_vpn.name
}
split_tunnel = true
vpc_id = aws_vpc.main.id
security_group_ids = [aws_security_group.allow_ssh.id]
}

Notice how we reference the server certificate issued which we previously imported into ACM — in both server_certificate_arn and root_certificate_chain_arn.

Since our client certificates will also be issued by the same CA, we can use the same certificate ARN as the trust anchor for both server and client authentication. This allows any client certificate signed by the same CA to be accepted during mutual TLS authentication.

We're also setting split_tunnel to true. This ensures that only traffic destined for resources inside the VPN is routed through the VPN tunnel, while all other internet traffic continues to go through the user's local network.

If we left split_tunnel set to its default value of false, all traffic—including internet-bound requests—would be routed through the VPN. Because the our fully-private VPC has no public internet access, this would effectively break the user's connection, blackholing their traffic the moment they connect.

If you're reading this and you intend to set this up with an internet-connected vpc, note that split_tunnel might still be useful for you if your security posture allows and you'd like to save on data transfer costs by only routing AWS-bound traffic through the VPN.

Associate the VPN endpoint with the VPC and subnet

To route VPN traffic into your VPC, you need to associate the endpoint with a target network — which is just a subnet in your VPC. This tells AWS where to send traffic from connected clients.

resource "aws_ec2_client_vpn_network_association" "subnet_b" {
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.org.id
subnet_id = aws_subnet.subnet_b.id
}

Add an authorization rule for the VPC

Even after associating a subnet, clients can’t access anything yet — you need to explicitly allow it with an authorization rule.

resource "aws_ec2_client_vpn_authorization_rule" "subnet_b" {
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.org.id
target_network_cidr = aws_vpc.main.cidr_block
authorize_all_groups = true
}

Connecting to Client VPN

To connect to the Client VPN, we'll need both the Client VPN endpoint configuration file and our generated client key and certificate.

Prepare the Client VPN endpoint configuration file

  1. Open the Amazon VPC console at https://console.aws.amazon.com/vpc/

  2. In the navigation pane, choose Client VPN Endpoints.

  3. Select the Client VPN endpoint that was just created, and choose Download client configuration.

  4. Open the Client VPN endpoint configuration file using your preferred text editor. Add <cert></cert> and <key> </key> tags to the file. Place the contents of the client certificate and the contents of the private key between the corresponding tags, as such:

<cert>
path to client certificate (.crt) file
</cert>

<key>
path to private key (.key) file
</key>
  1. Save and close the Client VPN endpoint configuration file.

  2. Add the line pull-filter ignore "redirect-gateway" to the ovpn file.

Note: Step 6 deserves a bit of explanation. During testing on a local device using the AWS-provided VPN client, I found that AWS Client VPN was still routing all traffic through the VPN—even though split_tunnel was enabled. The culprit was the redirect-gateway flag which the AWS provided client was setting.

Fortunately, pull-filter is one of the supported OpenVPN directives in the AWS client. By adding pull-filter ignore "redirect-gateway", we instruct the client to ignore that directive and preserve split-tunnel behavior.

Connect to the Client VPN endpoint

How you'll connect to the VPN depends on your OS as well as your VPN client. AWS provides its own custom OpenVPN client that is designed to be compatible with all features of AWS Client VPN. However, since the goal was to use this in an Ubuntu Linux GitHub Codespace, we'll just use the bog standard OpenVPN client.

First we'll install openvpn.

sudo apt-get update && sudo apt-get install openvpn

Then we can connect using:

sudo openvpn --config /path/to/config/file

If all went well, you should see this:

@mbuotidem ➜ /workspaces/mbuotidem.github.io (main) $ sudo openvpn --config o.ovpn
Mon May 19 00:40:17 2025 OpenVPN 2.4.12 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO]
[AEAD] built on Jun 27 2024
Mon May 19 00:40:17 2025 library versions: OpenSSL 1.1.1f 31 Mar 2020, LZO 2.10
Mon May 19 00:40:17 2025 TCP/UDP: Preserving recently used remote address: [AF_INET]3.XXX.XXX.72:443
Mon May 19 00:40:17 2025 Socket Buffers: R=[1048576->1048576] S=[212992->212992]
...

Mon May 19 00:40:19 2025 Outgoing Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
Mon May 19 00:40:19 2025 Incoming Data Channel: Cipher 'AES-256-GCM' initialized with 256 bit key
Mon May 19 00:40:19 2025 ROUTE_GATEWAY 10.0.0.1/255.255.0.0 IFACE=eth0 HWADDR=7c:1e:52:5b:bf:9b
Mon May 19 00:40:19 2025 TUN/TAP device tun0 opened
Mon May 19 00:40:19 2025 TUN/TAP TX queue length set to 100
Mon May 19 00:40:19 2025 /sbin/ip link set dev tun0 up mtu 1500
Mon May 19 00:40:19 2025 /sbin/ip addr add dev tun0 172.16.0.13/27 broadcast 172.16.0.31
Mon May 19 00:40:19 2025 /sbin/ip route add 172.31.0.0/16 via 172.16.0.1
Mon May 19 00:40:19 2025 Initialization Sequence Completed

You can now leave that terminal window running, open a new one, and ssh, curl, or otherwise connect to the resource in your private subnet!

On the AWS Side, you can see the connection info if you visit your VPN endpoint's info page. Notice how our common name shows up, indicating we're connected using a certificate.

Image showing an active AWS Client VPN connection with identifiers such as the x.509 certificate common name

Wrapping Up

That’s it! You've now used your SPIRE issued SVID twice, first to grab AWS credentials, via AWS IAM Anywhere, and now, to connect to AWS Client VPN.

In our next post, we'll move beyond the local developer machine context and explore using SPIFFE/SPIRE to grant Kubernetes clusters not running on AWS access to AWS API's vi IAM Roles Anywhere.