SMS Blog
Deploying AWS Client VPN with Okta using Terraform
Setting up secure and reliable remote access to your AWS infrastructure is crucial for many organizations, especially in today’s distributed work environment. AWS Client VPN offers a managed client-based VPN service that enables secure access to AWS resources and on-premises networks if desired. In this blog post, I’ll walk you through deploying AWS Client VPN using Terraform with a focus on our custom module for streamlined implementation.
Why AWS Client VPN?
AWS offers several remote access tools, each designed for specific purposes. AWS Systems Manager Session Manager provides secure shell access to individual EC2 instances, while AWS Verified Access delivers zero-trust, per-application access for web services. However, AWS Client VPN stands out as the preferred solution when your organization needs broad network access to your AWS environment.
Here’s why AWS Client VPN might be the ideal choice for your needs:
- Full Network Access, Not Just Individual Machines: Unlike tools that only provide access to a single server’s command line, Client VPN connects users’ computers directly to your AWS Virtual Private Cloud (VPC). This grants them an IP address within your AWS environment, enabling seamless access to databases, internal web applications, and file shares—just as if there workstation was directly deployed in the AWS VPC. Connectivity to on-premises networks can be provided via Client VPN if your VPC is connected to on-premises networks through technologies such as AWS Direct Connect or Site-to-Site VPN.
- Managed and Scalable VPN Infrastructure: AWS handles all deployment, maintenance, and patching of the VPN infrastructure. This eliminates the operational overhead of managing your own VPN servers while providing a solution that automatically scales with your growing remote workforce.
- Flexible and Centralized Security: Client VPN integrates with your existing identity systems, supporting authentication through Active Directory, AWS IAM Identity Center, SAML-based single sign-on (SSO) with providers like Okta or Auth0, and certificate-based mutual authentication. This integration lets you enforce strong, consistent security policies for all remote users.
- Fine-Grained Network Control: Beyond authentication, you can define granular authorization rules that control which users or groups can access specific network segments or resources. This helps enforce the principle of least privilege, ensuring users can only access systems relevant to their roles.
Alternative remote access solutions—such as but not limited to Cisco AnyConnect, Palo Alto Global Protect, Fortinet FortiClient VPN, Tailscale, WireGuard, and OpenVPN—exist but are beyond the scope of this article.
Understanding the Terraform Module
Our AWS Client VPN Terraform module simplifies deployment by abstracting away much of the complexity. The module handles several key components:
- SAML integration with Okta for authentication. While this deployment and module specifically use Okta for authentication, several other authentication options are available as mentioned above.
- Certificate management via AWS ACM
- DNS configuration with Route53
- CloudWatch logging setup
- Environment-specific authorization rules
Prerequisites
Before you begin, ensure you have:
- Terraform v1.0 or later installed
- AWS CLI configured with appropriate permissions
- SAML metadata from your Okta tenant (Create AWS Client VPN App). As noted above, while AWS Client VPN supports multiple authentication methods, this module specifically implements Okta SAML-based SSO.
- A Route53 hosted zone for certificate validation
- VPC and subnets already deployed
Deployment Walkthrough
Creating the SAML Okta provider (this requires metadata generated when setting up the AWS Client VPN Okta app)
# SAML Provider resource "aws_iam_saml_provider" "okta" { name = var.saml_provider_name saml_metadata_document = file(var.saml_metadata_path) }
We use the aws_iam_saml_provider
terraform resource and provide it a name for the provider and the path to the Okta metadata file. This creates an IAM SAML provider leveraging your existing Okta identity management, including multi-factor authentication and group-based access controls. The SAML provider name can be customized using the saml_provider_name
variable.
Setting Up CloudWatch
# IAM Role for CloudWatch Logging resource "aws_iam_role" "vpn_cloudwatch_role" { name = "client-vpn-cloudwatch-logging-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }, { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "vpc-flow-logs.amazonaws.com" } } ] }) tags = merge( var.tags, { Name = "client-vpn-cloudwatch-role" } ) } # IAM Policy for CloudWatch Logging resource "aws_iam_role_policy" "vpn_cloudwatch_policy" { name = "client-vpn-cloudwatch-logging-policy" role = aws_iam_role.vpn_cloudwatch_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams" ] Resource = "${aws_cloudwatch_log_group.vpn_logs.arn}:*" } ] }) } # CloudWatch Log Group resource "aws_cloudwatch_log_group" "vpn_logs" { name = "/aws/client-vpn/${aws_acm_certificate.server.domain_name}" retention_in_days = var.log_retention_days kms_key_id = var.cloudwatch_kms_key_arn tags = merge( var.tags, { Name = "client-vpn-logs" } ) } # CloudWatch Log Stream resource "aws_cloudwatch_log_stream" "vpn_log_stream" { name = var.logging_stream_name log_group_name = aws_cloudwatch_log_group.vpn_logs.name }
Using the aws_iam_role
, aws_iam_role_policy
, aws_cloudwatch_log_group
, and aws_cloudwatch_log_stream
resources, we deploy CloudWatch integration for monitoring and troubleshooting your Client VPN connections. It creates a dedicated log group with a customizable retention period, a log stream for connection events, and the necessary IAM role and policy for proper logging permissions. CloudWatch logs capture important events such as client connections, disconnections, and authentication attempts, providing visibility into who’s accessing your VPN and when. KMS is used to encrypt the log data which can contain sensitive information such as IPs and usernames that are captured in the log entries. You can easily modify the log retention period through the log_retention_days
variable (default is 30 days) as needed or required.
Create the SSL Certificate
# Server Certificate resource "aws_acm_certificate" "server" { domain_name = "client-vpn.${var.aws_route53_zonename}" validation_method = "DNS" tags = merge( var.tags, { Name = "client-vpn-server-cert" } ) lifecycle { create_before_destroy = true } }
Using the aws_acm_certificate
resource, we create an SSL certificate through AWS Certificate Manager (ACM), generating a server certificate for the VPN endpoint with the domain name pattern of “client-vpn.${var.aws_route53_zonename}
“. This certificate is validated using DNS validation records, which the module can optionally create in your Route53 hosted zone, streamlining the certificate issuance process and eliminating the need for manual verification steps.
Create Route53 Records
# Create Route53 record for server certificate domain resource "aws_route53_record" "vpn_endpoint" { count = var.create_dns_record ? 1 : 0 zone_id = var.route53_zone_id name = trimsuffix(aws_acm_certificate.server.domain_name, ".${var.aws_route53_zonename}") type = "A" alias { name = aws_ec2_client_vpn_endpoint.main.dns_name zone_id = var.route53_zone_id evaluate_target_health = true } } # Create Route53 validation records for ACM certificate resource "aws_route53_record" "cert_validation" { for_each = var.create_dns_record ? { for dvo in aws_acm_certificate.server.domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } : {} allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 60 type = each.value.type zone_id = var.route53_zone_id }
If the route53 hosted zone resides in the same account that the AWS Client VPN will be deployed to, you can have the module create the route53 records for the VPN service as well as the records required to validate the SSL certificate created above. The use of the create_dns_record
boolean variable drives if these resources are created for you or not via Terraform.
Create the VPN Endpoint
# Client VPN Endpoint resource "aws_ec2_client_vpn_endpoint" "main" { description = "Client VPN with Okta authentication" server_certificate_arn = aws_acm_certificate.server.arn client_cidr_block = var.client_cidr_block vpc_id = var.vpc_id authentication_options { type = "federated-authentication" saml_provider_arn = aws_iam_saml_provider.saml_provider.arn self_service_saml_provider_arn = aws_iam_saml_provider.saml_provider.arn } connection_log_options { enabled = var.logging_enabled cloudwatch_log_group = aws_cloudwatch_log_group.vpn_logs.name cloudwatch_log_stream = aws_cloudwatch_log_stream.vpn_log_stream.name } 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." } transport_protocol = var.vpn_transport_protocol split_tunnel = var.split_tunnel dns_servers = var.client_dns_servers self_service_portal = "enabled" tags = merge( var.tags, { Name = "client-vpn-endpoint" } ) }
We create the AWS Client VPN endpoint using the aws_ec2_client_vpn_endpoint
resource, which serves as the core component of the VPN solution. It configures the endpoint in the identified VPC with your specified client CIDR block, attaches the automatically generated server certificate, enables the CloudWatch logging, and sets up federated authentication through the Okta SAML provider. The session_timeout_hours
defines the maximum session duration after which end-users are required to re-authenticate before continuing their VPN session. The transport protocol for the VPN is defined with the vpn_transport_protocol
variable and DNS servers are defined for the clients using the client_dns_servers
variable. The endpoint supports split tunnel mode (configurable via the split_tunnel
variable), which allows clients to route only specific traffic through the VPN, reducing bandwidth usage and improving performance for internet-bound traffic. The resource also sets a banner that is sent to users upon successful connection to the VPN service. The self_service_portal = "enabled"
enables a self-service web portal where users can download VPN client configuration files without administrator intervention.
As of this writing, AWS Client VPN does not offer an idle timeout for client inactivity.
Associate the VPC Subnets
# VPN Subnet Associations resource "aws_ec2_client_vpn_network_association" "subnets" { for_each = toset(var.subnet_ids) client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id subnet_id = each.value }
Here we dynamically associate the Client VPN endpoint with multiple subnets through the aws_ec2_client_vpn_network_association
resource, which creates a connection between your VPN and each subnet specified in the subnet_ids
variable. These associations determine where VPN client traffic can be routed within your VPC and are essential for providing access to resources across different availability zones, enabling high availability and fault tolerance for your VPN connections.
Creating Authorization Rules
# All Groups Authorization Rule resource "aws_ec2_client_vpn_authorization_rule" "all" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_network_cidr = "0.0.0.0/0" authorize_all_groups = true description = "Authorization rule for all not matched" } # Prod Environment Authorization Rule resource "aws_ec2_client_vpn_authorization_rule" "prod" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_network_cidr = lookup(var.environment_cidrs, "prod", "10.1.0.0/16") access_group_id = lookup(var.environment_groups, "prod", "prod-group-id") description = "Authorization rule for prod environment" } # Stage Environment Authorization Rule resource "aws_ec2_client_vpn_authorization_rule" "stage" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_network_cidr = lookup(var.environment_cidrs, "stage", "10.2.0.0/16") access_group_id = lookup(var.environment_groups, "stage", "stage-group-id") description = "Authorization rule for stage environment" } # Dev Environment Authorization Rule resource "aws_ec2_client_vpn_authorization_rule" "dev" { client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_network_cidr = lookup(var.environment_cidrs, "dev", "10.3.0.0/16") access_group_id = lookup(var.environment_groups, "dev", "dev-group-id") description = "Authorization rule for prod environment" }
Here we create environment-specific rules that map particular Okta groups to their corresponding defined CIDR block, allowing you to restrict access to development, staging, and production environments based on a user’s role or team. The fallback “all groups” authorization rule with the target_network_cidr
of “0.0.0.0/0” allows access to all networks and internet for any authenticated user, while still respecting the more specific environment-based rules. This all rule is evaluated last and allows access to resources not defined by the previous more specific rules.
Create Security Group for VPN Users
resource "aws_security_group" "client_vpn" { name_prefix = "client-vpn-" vpc_id = var.vpc_id ingress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = [var.client_cidr_block] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = merge( var.tags, { Name = "client-vpn-sg" } ) }
Using the aws_security_group
resource, we create a dedicated security group for the Client VPN endpoint that controls network traffic flow with two essential rules: an ingress rule that allows all incoming traffic from the VPN client CIDR range, ensuring connected clients can communicate with resources in your VPC, and an egress rule that permits all outbound traffic, enabling resources within your VPC to communicate back to VPN clients.
VPN Route Creation
# VPN Routes resource "aws_ec2_client_vpn_route" "default" { for_each = aws_ec2_client_vpn_network_association.subnets description = "Default route for Client VPN" destination_cidr_block = "0.0.0.0/0" client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_vpc_subnet_id = each.value.subnet_id depends_on = [ aws_ec2_client_vpn_network_association.subnets ] timeouts { create = "5m" delete = "5m" } } resource "aws_ec2_client_vpn_route" "prod_route" { for_each = aws_ec2_client_vpn_network_association.subnets description = "Route for prod environment" destination_cidr_block = lookup(var.environment_cidrs, "prod", "10.1.0.0/16") client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_vpc_subnet_id = each.value.subnet_id depends_on = [ aws_ec2_client_vpn_network_association.subnets ] timeouts { create = "5m" delete = "5m" } } resource "aws_ec2_client_vpn_route" "stage_route" { for_each = aws_ec2_client_vpn_network_association.subnets description = "Route for stage environment" destination_cidr_block = lookup(var.environment_cidrs, "stage", "10.2.0.0/16") client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_vpc_subnet_id = each.value.subnet_id depends_on = [ aws_ec2_client_vpn_network_association.subnets ] timeouts { create = "5m" delete = "5m" } } resource "aws_ec2_client_vpn_route" "dev_route" { for_each = aws_ec2_client_vpn_network_association.subnets description = "Route for dev environment" destination_cidr_block = lookup(var.environment_cidrs, "dev", "10.3.0.0/16") client_vpn_endpoint_id = aws_ec2_client_vpn_endpoint.main.id target_vpc_subnet_id = each.value.subnet_id depends_on = [ aws_ec2_client_vpn_network_association.subnets ] timeouts { create = "5m" delete = "5m" } }
Through use of the aws_ec2_client_vpn_route
resource, we create routes that direct traffic from VPN clients to specific destinations within your AWS environment. For each subnet association, it creates environment-specific routes for development, staging, and production networks, ensuring traffic is properly directed to the appropriate environments based on their CIDR blocks. Additionally, it configures a default route (destination “0.0.0.0/0”) for each subnet association, which serves as a catch-all to handle traffic not explicitly matched by the environment-specific routes.
Create your Terraform Configuration
Create a main.tf
file with the module configuration:
module "client_vpn" { source = "./aws-client-vpn-module" vpc_id = "vpc-0123456789abcdef0" client_cidr_block = "10.200.0.0/24" aws_route53_zonename = "mycompany.com" subnet_ids = ["subnet-0123456789abcdef1", "subnet-0123456789abcdef2"] saml_metadata_path = "./okta-metadata.xml" saml_provider_name = "OktaClientVPN" vpn_transport_protocol = "tcp" logging_enabled = true client_dns_servers = ["10.100.0.2"] environment_cidrs = { dev = "10.2.0.0/16" stage = "10.1.0.0/16" prod = "10.0.0.0/16" } environment_groups = { dev = "001234214adsf302312d", stage = "00ewlsh32830cvs94123", prod = "00abcdef2123eafis332" } # Optional configurations create_dns_record = false # Optional: customize SAML provider name saml_provider_name = "CustomOktaVPN" tags = { Environment = "security" Project = "remote-access" Terraform = "true" } }
Initialize and Apply
Run the following commands to deploy:
terraform init terraform plan terraform apply
Advanced Configuration Options
Split Tunnel Mode
The module supports split tunnel mode, which allows clients to route only specific traffic through the VPN:
module "client_vpn" { # Other configuration... split_tunnel = true }
Create DNS Records
module "client_vpn" { # Other configuration... create_dns_record = true }
Define Custom SAML Provider Name
module "client_vpn" { # Other configuration... saml_provider_name = "CustomOktaVPN" }
Client Installation
After setting up the AWS Client VPN endpoint, users need to install and configure the client software on their devices. Follow these steps to get connected:
Download the AWS Client VPN Application
Important: If using SAML-based federated authentication, as is used in this deployment and module, you must use the AWS provided client.
The AWS Client VPN service uses the OpenVPN-based client software. Download the appropriate version for your operating system:
- Windows: Download the AWS provided client from the AWS Client VPN download page
- macOS: Download the AWS provided client from the AWS Client VPN download page
- Linux: Install the AWS provided client using your distribution’s package manager
Obtain the Client Configuration File
There are two ways to obtain the required configuration file:
1. Self-Service Portal (Recommended)
If the self-service portal is enabled (which it is in our module):
- Navigate to the self-service portal URL (available in the AWS Console under VPN Endpoints)
- Authenticate using your Okta credentials
- Download the client configuration file
2. Administrator-Generated Configuration
Alternatively, administrators can generate configuration files:
aws ec2 export-client-vpn-client-configuration \\ --client-vpn-endpoint-id cvpn-endpoint-0123456789abcdef0 \\ --output text > client-config.ovpn
Configure the Client
- Install the AWS VPN Client or OpenVPN application
- Import the downloaded .ovpn configuration file into the client application
- For SAML authentication users, ensure your browser is accessible for the authentication flow
Connect to the VPN
To establish a connection:
- Open the AWS VPN Client or OpenVPN application
- Select the imported profile
- Click “Connect”
- When prompted, authenticate using your Okta credentials
- Once connected, you’ll be able to access resources according to the authorization rules
Outputs
After deployment, you can monitor your VPN through the AWS Console or by utilizing the CloudWatch logs. The module outputs several useful identifiers:
client_vpn_endpoint_id
: The ID of your VPN endpointclient_vpn_endpoint_dns
: The DNS name for connecting clientsvpn_domain_name
: The friendly domain name created in Route53cloudwatch_log_group_name
: For monitoring connections and troubleshooting
Security Considerations
The module includes reasonable security defaults, but you should review:
- The security group rules to ensure they match your security requirements
- The authorization rules to verify proper access restrictions
- The IAM role permissions for CloudWatch logging
Conclusion
Deploying AWS Client VPN with Terraform provides a scalable, secure, and reproducible approach to remote access management. Using these resources simplifies the process while maintaining the flexibility to customize according to your organization’s needs.
By leveraging infrastructure as code, you gain the ability to version control your VPN configuration, apply consistent deployments across environments, and quickly recover from any issues that might arise.