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:

  1. Terraform v1.0 or later installed
  2. AWS CLI configured with appropriate permissions
  3. 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.
  4. A Route53 hosted zone for certificate validation
  5. 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:

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):

  1. Navigate to the self-service portal URL (available in the AWS Console under VPN Endpoints)
  2. Authenticate using your Okta credentials
  3. 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

  1. Install the AWS VPN Client or OpenVPN application
  2. Import the downloaded .ovpn configuration file into the client application
  3. For SAML authentication users, ensure your browser is accessible for the authentication flow

Connect to the VPN

To establish a connection:

  1. Open the AWS VPN Client or OpenVPN application
  2. Select the imported profile
  3. Click “Connect”
  4. When prompted, authenticate using your Okta credentials
  5. 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 endpoint
  • client_vpn_endpoint_dns: The DNS name for connecting clients
  • vpn_domain_name: The friendly domain name created in Route53
  • cloudwatch_log_group_name: For monitoring connections and troubleshooting

Security Considerations

The module includes reasonable security defaults, but you should review:

  1. The security group rules to ensure they match your security requirements
  2. The authorization rules to verify proper access restrictions
  3. 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.

Leave a Comment