Key Takeaways
- AWS Client VPN provides secure, certificate-based remote access to VPC resources, ideal for distributed teams working remotely.
- The implementation uses a three-tier certificate system (root, server, client) managed through AWS Certificate Manager for enhanced security and access control.
- Split tunneling can be enabled to optimize routing and reduce data transfer costs by only routing AWS-bound traffic through the VPN.
- The solution includes comprehensive logging via CloudWatch for audit and compliance purposes, tracking connection attempts, duration, and user activity.
- Security is maintained through proper subnet associations, authorization rules, and security group configurations, ensuring protected access to resources like RDS instances.
Is Your HealthTech Product Built for Success in Digital Health?
In this article, we’ll walk through implementing AWS Client VPN using Terraform. I also want to outline the issue of providing secure connections to your infrastructure, in this case AWS VPC, particularly when an entire company is working fully remotely, which is a common case nowadays.
If you want to jump to a ready-to-use module, check out the links in the Summary section.
Problem
Secure network connectivity is crucial when building modern systems, especially for organizations with distributed teams. While traditional site-to-site VPNs work well for connecting office locations, they don’t address the needs of remote employees working from different parts of the world with dynamic IP addresses.
I recently faced this challenge when implementing a VPN solution for a fully remote company that needed to access some parts of their infrastructure (DB read replicas, Superset for analytics) in a secure way due to compliance reasons. Each employee required secure VPC access regardless of their current IP address, so Site-to-Site VPN and connecting to the office was not an option.
The pattern that I found suitable for this case is Client VPN—a managed client-based VPN service that provides secure and encrypted access to on-premises networks and automatically scales up to the number of users.
It uses self-signed certificates for authentication instead of IP addresses and also provides TLS encryption. It allows company employees to access their VPC resources without exposing them publicly from any place they work in and manages access using tunnel connections efficiently.
Theory
AWS Client VPN uses a Client VPN endpoint to provide connection to VPC. The client establishes the VPN session from their local computer or mobile device using an OpenVPN-based VPN client application. After they have established the VPN session, they can securely access the resources in the VPC where the associated subnet is located.
Later, you can also implement Virtual Private Gateway, Site-to-Site VPN, and Customer Gateway to “pipe” this traffic to your on-premises network and treat VPC as a “proxy”.
Under the hood, AWS VPN Client enhances OpenVPN.
An in-depth look at how Client VPN works is as follows:
- First, the Client resolves DNS of the Client VPN endpoint and then initiates contact
- The Client initiates a UDP connection to the server and creates a virtual TUN interface for tunneled traffic
- The Client and servers perform a TLS handshake, and when validated, both sides agree on encryption parameters
- The TUN interface is configured with an IP address, routes are set up for tunneled traffic, and the connection is ready to use
Note that we use manual authentication that uses certificates to perform authentication between client and servers. Certificates are a digital form of identification issued by a certificate authority (CA). The server uses client certificates to authenticate clients when they attempt to connect to the Client VPN endpoint.
You can create a separate client certificate and key for each client that will connect to the Client VPN endpoint. This enables you to revoke a specific client certificate if a user leaves your organization. In this case, when you create the Client VPN endpoint, you can specify the server certificate ARN for the client certificate, provided that the client certificate has been issued by the same CA as the server certificate.
It is also possible to create custom logic for authorization by enhancing AWS Lambda functions.
Scheme
In this article, I focus on connecting to a single VPC, but when you need to connect to two or more VPCs, you can enhance VPC peering connection to do so. It is also possible to do Client to Client connection.
Note:
- You need to create or identify a VPC with at least one subnet. Identify the subnet in the VPC to associate with the Client VPN endpoint and note its IPv4 CIDR ranges.
- Identify a suitable CIDR range for the client IP addresses that does not overlap with the VPC CIDR.
Prerequisites
- Terraform installed on your system (version 1.9 was used for this tutorial)
- VPC with at least one subnet
- AWS admin access credentials (access key and secret key) configured for authentication
Overview
Here is what we need to do:
1. Create certificates for root, client, and server and upload them into AWS Certificate Manager
2. Create a VPN Endpoint in the same Region as the VPC
3. Associate the subnet with the Client endpoint
4. Add an authorization rule to give clients access to the VPC
5. Add a rule to your resources’ security groups to allow traffic from the security group that was applied to the subnet association in step 2
Chapter 1: Certificates
First, we need to create a Root certificate that will be used to generate server and client certificates:
################################################################################
# CA Key and Certificate (Root CA)
################################################################################
resource "tls_private_key" "ca_key" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "tls_self_signed_cert" "ca_cert" {
private_key_pem = tls_private_key.ca_key.private_key_pem
subject {
common_name = "VPN Root CA"
organization = var.organization_name
country = "US" # Added for compliance
}
validity_period_hours = 87600 # 10 years
is_ca_certificate = true
allowed_uses = [
"cert_signing",
"crl_signing",
"digital_signature",
"key_encipherment"
]
}
Next, as mentioned, we generate server and client certificates and keys:
################################################################################
# VPN Server Key and Certificate
################################################################################
resource "tls_private_key" "vpn_key" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "tls_cert_request" "vpn_csr" {
private_key_pem = tls_private_key.vpn_key.private_key_pem
subject {
common_name = var.vpn_domain
organization = var.organization_name
country = "US"
}
}
resource "tls_locally_signed_cert" "vpn_cert" {
cert_request_pem = tls_cert_request.vpn_csr.cert_request_pem
ca_private_key_pem = tls_private_key.ca_key.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem
validity_period_hours = 8760 # 1 year
allowed_uses = [
"digital_signature",
"key_encipherment",
"server_auth",
"client_auth",
]
set_subject_key_id = true
}
################################################################################
# Client Key and Certificate
################################################################################
resource "tls_private_key" "client_key" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "tls_cert_request" "client_csr" {
private_key_pem = tls_private_key.client_key.private_key_pem
subject {
common_name = "client.${var.vpn_domain}"
organization = var.organization_name
country = "US"
}
}
resource "tls_locally_signed_cert" "client_cert" {
cert_request_pem = tls_cert_request.client_csr.cert_request_pem
ca_private_key_pem = tls_private_key.ca_key.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca_cert.cert_pem
validity_period_hours = var.certificate_validity_period_hours
allowed_uses = [
"digital_signature",
"key_encipherment",
"client_auth",
]
set_subject_key_id = true
}
Note: your state contains sensitive information and you should keep it safe. I strongly advise running your Terraform code from a pipeline and keeping the state isolated and accessible only to authorized users.
Lastly, we import Client and server certificates to AWS Certificate Manager:
################################################################################
# Import Certificates to ACM
################################################################################
resource "aws_acm_certificate" "vpn_cert" {
private_key = tls_private_key.vpn_key.private_key_pem
certificate_body = tls_locally_signed_cert.vpn_cert.cert_pem
certificate_chain = tls_self_signed_cert.ca_cert.cert_pem
tags = var.tags
}
resource "aws_acm_certificate" "ca_cert" {
private_key = tls_private_key.ca_key.private_key_pem
certificate_body = tls_self_signed_cert.ca_cert.cert_pem
tags = var.tags
}
Chapter 2: Client VPN Endpoint
The Client VPN endpoint is the resource that you create and configure to enable and manage client VPN sessions. It’s the termination point for all client VPN sessions.
################################################################################
# Create Client VPN Endpoint #
################################################################################
resource "aws_security_group" "vpn" {
name_prefix = "client-vpn-endpoint-sg"
description = "Security group for Client VPN endpoint"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "udp"
cidr_blocks = var.allowed_cidr_blocks
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = var.tags
}
resource "aws_ec2_client_vpn_endpoint" "vpn" {
description = "Client VPN endpoint"
server_certificate_arn = aws_acm_certificate.vpn_cert.arn
client_cidr_block = var.client_cidr_block
vpc_id = var.vpc_id
split_tunnel = var.split_tunnel
authentication_options {
type = "certificate-authentication"
root_certificate_chain_arn = aws_acm_certificate.ca_cert.arn
}
transport_protocol = "udp"
security_group_ids = [aws_security_group.vpn.id]
connection_log_options {
enabled = true
cloudwatch_log_group = aws_cloudwatch_log_group.vpn_logs.name
cloudwatch_log_stream = aws_cloudwatch_log_stream.vpn_logs.name
}
dns_servers = ["169.254.169.253"]
session_timeout_hours = 8
client_login_banner_options {
enabled = true
banner_text = "This VPN is for authorized users only. All activities may be monitored and recorded."
}
tags = merge(var.tags, {
Name = "client-vpn-${var.vpn_domain}"
})
}
As mentioned in steps 3 and 4, we need to associate the subnet and add authorization rules:
resource "aws_ec2_client_vpn_network_association" "vpn_subnet" {
for_each = toset(var.subnet_ids)
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id
subnet_id = each.value
}
resource "aws_ec2_client_vpn_authorization_rule" "vpn_auth_rule" {
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id
target_network_cidr = var.target_network_cidr
authorize_all_groups = true
}
For audit and compliance purposes, we need to implement logging:
################################################################################
# Logging
################################################################################
resource "aws_cloudwatch_log_group" "vpn_logs" {
# encrypted by default
name = "/aws/vpn/${var.vpn_domain}"
retention_in_days = 2192 # 6 years
tags = var.tags
}
resource "aws_cloudwatch_log_stream" "vpn_logs" {
name = "vpn-connection-logs"
log_group_name = aws_cloudwatch_log_group.vpn_logs.name
}
Using this logging, we collect information about connection attempts, who connected to VPN, when and for how long, from which IP, and more.
{
"connection-log-type": "connection-attempt",
"connection-attempt-status": "successful",
"connection-attempt-failure-reason": "NA",
"connection-id": "cvpn-connection-123",
"client-vpn-endpoint-id": "cvpn-endpoint-123",
"transport-protocol": "udp",
"connection-start-time": "2025–01–14 16:41:57",
"connection-last-update-time": "2025–01–14 16:41:57",
"client-ip": "10.100.0.163",
"common-name": "client.vpn.example.com",
"device-type": "mac",
"device-ip": "123.123.123.123",
"port": "1234",
"ingress-bytes": "0",
"egress-bytes": "0",
"ingress-packets": "0",
"egress-packets": "0",
"connection-end-time": "NA",
"connection-duration-seconds": "0"
}
Split tunneling
When setting up the Client VPN Endpoint, we used this option:
split_tunnel = var.split_tunnel
By default, when you have a Client VPN endpoint, all traffic from clients is routed over the Client VPN tunnel.
When you enable split-tunnel on the Client VPN endpoint, we push the routes from the Client VPN endpoint route table to the device that is connected to the Client VPN endpoint.
This ensures that only traffic with a destination to the network matching a route from the Client VPN endpoint route table is routed over the Client VPN tunnel.
You can use a split-tunnel Client VPN endpoint when you do not want all user traffic to route through the Client VPN endpoin
Split-tunnel on Client VPN endpoints offers the following benefits:
- You can optimize the routing of traffic from clients by having only AWS-destined traffic traverse the VPN tunnel
- You can reduce the volume of outgoing traffic from AWS, thereby reducing the data transfer cost
- Access public internet while being connected to AWS Client VPN
Chapter 3: Client Configuration and Access
We need to generate an OpenVPN config so we can provide it to VPN users. Let’s automate it:
################################################################################
# Generate Client VPN Config File
################################################################################
data "aws_ec2_client_vpn_endpoint" "selected" {
client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.vpn.id
depends_on = [
aws_ec2_client_vpn_endpoint.vpn
]
}
resource "local_file" "vpn_config" {
filename = "${path.root}/client.ovpn"
content = <<-EOT
client
dev tun
proto udp
remote ${aws_ec2_client_vpn_endpoint.vpn.dns_name} 443
remote-random-hostname
resolv-retry infinite
nobind
remote-cert-tls server
cipher AES-256-GCM
verify-x509-name ${var.vpn_domain} name
reneg-sec 0
verb 3
<ca>
${tls_self_signed_cert.ca_cert.cert_pem}
</ca>
<cert>
${tls_locally_signed_cert.client_cert.cert_pem}
</cert>
<key>
${tls_private_key.client_key.private_key_pem}
</key>
EOT
file_permission = "0600"
depends_on = [
aws_ec2_client_vpn_endpoint.vpn,
tls_locally_signed_cert.client_cert,
tls_private_key.client_key,
tls_self_signed_cert.ca_cert
]
}
The output of this Terraform module is a client.ovpn
configuration that will be passed to end users. Treat it as your login and password, as ANYONE with this configuration will be able to access the protected VPC.
When running Terraform on a CI/CD pipeline, you keep it as an artifact, and when running locally, you can just access this file after terraform apply
The file, named client.ovpn
, contains the following components:
- CA certificate
- Client certificate
- Client key
- OpenVPN configuration
To establish the connection, you can either use the AWS VPN Client application or the OpenVPN client. To configure the AWS VPN Client application, follow these steps:
File ->
Manage Profiles ->
Add Profile ->
Fill a name and add path to the client.ovpn file ->
Add profile
Then pick the profile and click connect. If the connection is successful, you will see ‘Connected’ status.
Chapter 4: Testing
Let’s say an end user wants to access an RDS database from their local machine. We don’t want to expose the RDS endpoint publicly, but we can easily achieve this task using Client VPN. To enable this, we need to update the RDS (or other resources like EC2) security group to allow access from the VPN tunnel.
resource "aws_security_group_rule" "rds_vpn_access" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = "sg-123123" # your RDS security group
source_security_group_id = module.vpn.security_group_id
description = "Allow VPN clients to access RDS"
}
Now test connection without connecting to VPN:
psql -h database-1.123.<region>.rds.amazonaws.com -p 5432 -U postgres -d postgres
psql: error: connection to server at "database-1.123.<region>.rds.amazonaws.com" (123.123.123.123), port 5432 failed: Operation timed out
Is the server running on that host and accepting TCP/IP connections?
Assuming you followed Chapter 3 steps and managed successfully connect to VPN you should access your test RDS from local machine:
psql -h database-1.123.<region>.rds.amazonaws.com -p 5432 -U postgres -d postgres
Password for user postgres:
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
postgres=>
Summary
We’ve covered a small part of AWS’s available tools regarding VPN, but I find this one quite straightforward, and it works really well in production environments. I hope it also helps to understand how VPN works in general.
For a ready-to-use module implementation, check out the Momentum’s HealthStack repository that I am currently building!
So You're Looking to Implement Secure Remote Access?
At Momentum, we understand the challenges of managing secure infrastructure access for distributed teams. Whether you're implementing Client VPN, exploring other connectivity solutions, or building a comprehensive security architecture, our team of cloud experts is here to help. We specialize in designing and implementing scalable, secure, and compliant infrastructure solutions that meet your organization's unique needs. Let's transform your infrastructure challenges into robust, production-ready solutions. Contact us today to learn more about how we can help secure your cloud infrastructure!