Using HashiCorp Vault as an SSH Certificate Authority
Replace self-signed CAs with Vault-managed certificate authorities for production Gateway deployments.
Production Environments
The information in this article should be used as a guide only. If you are deploying this method into a production environment, we recommend that you also follow all security and configuration best practices.
Code samples in this guide may contain references to specific versions of software or container images that may not be the latest versions available. Please refer to the official documentation for that software or container image for the most up-to-date information.
This guide builds on the Getting Started with IDFW SSH using Terraform guide. That guide uses the HashiCorp tls provider to generate self-signed CAs, which is fine for development and testing but stores private keys unencrypted in Terraform state. For production deployments, we recommend using HashiCorp Vault as your Certificate Authority instead.
With Vault, CA private keys are generated and stored inside Vault. They never appear in Terraform state, on disk, or in startup scripts. The Gateway authenticates to Vault at runtime to request signed certificates, and Vault provides audit logging for every signing operation.
A complete, runnable Terraform example is available in the examples/gce-gateway-ssh-vault directory of the Terraform provider repository. This guide highlights the key differences from the self-signed approach.
What changes with Vault
| Component | Self-Signed (base guide) | Vault |
|---|---|---|
| SSH CA key pair | tls_private_key resource, private key stored in Terraform state | vault_ssh_secret_backend_ca with generate_signing_key, private key stays inside Vault |
| X.509 CA | tls_self_signed_cert, private key stored in Terraform state | vault_pki_secret_backend_root_cert, private key stays inside Vault |
| Gateway config | ssh.ca.private_key_file pointing to a key file on the Gateway VM | ssh.ca.vault block with Vault address, mount path, signing role, and auth config |
| SSH server trust | Static CA public key written via cloud-init | VM authenticates to Vault at startup to obtain a signed host certificate |
Prerequisites
- Everything listed in the Terraform guide prerequisites, except that this example uses GCP instead of DigitalOcean
- A HashiCorp Vault instance with the SSH secrets engine enabled (self-hosted, HCP Vault, or the included sample deployment)
- A GCP project with the Compute Engine API enabled
- The
gcloudCLI installed and authenticated
The example deploys on GCP and uses GCP auth for Vault authentication. Vault supports many auth methods (AWS, Azure, Kubernetes, etc.), so the same pattern works on other cloud providers with the appropriate auth backend.
Configure the Vault SSH secrets engine
The SSH secrets engine is the core of this setup. It generates the SSH CA key pair and signs certificates on demand.
Mount and CA key
resource "vault_mount" "ssh" { path = "ssh" type = "ssh"}
resource "vault_ssh_secret_backend_ca" "ssh" { backend = vault_mount.ssh.path generate_signing_key = true key_type = "ssh-ed25519"}Setting generate_signing_key = true tells Vault to create the ED25519 key pair internally. The public key is available via vault_ssh_secret_backend_ca.ssh.public_key for registering with Twingate and configuring SSH servers.
Signing roles
Signing roles define what types of certificates Vault can issue. The Gateway needs a role that allows both user and host certificates:
resource "vault_ssh_secret_backend_role" "gateway" { name = "gateway" backend = vault_mount.ssh.path
key_type = "ca" ttl = "720h" # 30 days max_ttl = "8760h" # 365 days
allow_empty_principals = true allow_host_certificates = true allow_user_certificates = true allowed_domains = "*" allowed_users = "gateway" allowed_extensions = "permit-X11-forwarding,permit-agent-forwarding,permit-port-forwarding,permit-pty,permit-user-rc"}A Vault policy scopes the Gateway’s access to only its signing endpoint and the CA’s public key:
resource "vault_policy" "gateway" { name = "gateway-signing"
policy = <<-EOT path "${vault_mount.ssh.path}/sign/${vault_ssh_secret_backend_role.gateway.name}" { capabilities = ["create", "update"] } path "${vault_mount.ssh.path}/config/ca" { capabilities = ["read"] } EOT}Create a separate role for SSH servers that is restricted to host certificates only. See the full example for the complete configuration.
Configure the Vault PKI engine (optional)
The PKI secrets engine can issue the X.509 certificates used for TLS between the Twingate Client and the Gateway. This is optional — any X.509 CA source works (including the tls provider from the base guide, or your organization’s existing PKI).
resource "vault_mount" "pki" { path = "pki" type = "pki" description = "PKI backend for X.509 certificates" default_lease_ttl_seconds = 3600 max_lease_ttl_seconds = 31536000 # 1 year}
resource "vault_pki_secret_backend_root_cert" "root" { backend = vault_mount.pki.path type = "internal" common_name = "Demo Root CA" ttl = "8760h" # 1 year}A server certificate is issued from this CA at apply time and injected into the Gateway VM via templatefile(). See the full example for the vault_pki_secret_backend_cert resource configuration.
Set up Vault authentication
VMs authenticate to Vault using GCP identity tokens. Each VM role is scoped to specific policies:
resource "vault_auth_backend" "gcp" { type = "gcp"}
resource "vault_gcp_auth_backend_role" "gateway" { backend = vault_auth_backend.gcp.path role = "gateway-role" type = "gce" token_policies = [vault_policy.gateway.name] token_ttl = 86400 # 24 hours token_max_ttl = 86400 bound_projects = [var.project_id] bound_zones = [var.zone] bound_service_accounts = [google_service_account.gateway.email]}The bound_service_accounts field ensures that only the Gateway’s specific GCP service account can authenticate with this role. The SSH server has its own role scoped to a different policy that only permits host certificate signing.
For development or non-GCP environments, you can use token auth instead. The Gateway configuration supports both auth.gcp and auth.token blocks. Token auth is simpler but requires distributing a Vault token to the Gateway VM.
Register CAs with Twingate
The Twingate resources are the same as in the base guide, but the CA values come from Vault instead of the tls provider:
resource "twingate_ssh_certificate_authority" "vault" { name = "Vault SSH CA" public_key = vault_ssh_secret_backend_ca.ssh.public_key}
resource "twingate_x509_certificate_authority" "vault" { name = "Vault X509 CA" certificate = vault_pki_secret_backend_root_cert.root.certificate}
resource "twingate_gateway" "vault" { remote_network_id = twingate_remote_network.main.id address = "${google_compute_address.gateway.address}:${local.gateway_port}" x509_ca_id = twingate_x509_certificate_authority.vault.id ssh_ca_id = twingate_ssh_certificate_authority.vault.id}The remaining Twingate resources (Remote Network, Connector, SSH Resource) are configured the same way as in the base guide.
Configure the Gateway for Vault
This is the key difference from the self-signed approach. Instead of pointing to a local private key file, the Gateway configuration includes a vault block that tells the Gateway how to authenticate to Vault and request signed certificates at runtime.
In the base guide, the Gateway config uses:
ssh = { ca = { private_key_file = "/opt/gateway/ssh-ca.key" }}With Vault, it becomes:
resource "twingate_gateway_config" "config" { port = local.gateway_port
tls = { certificate_file = "/opt/gateway/tls.crt" private_key_file = "/opt/gateway/tls.key" }
ssh = { gateway = { username = "gateway" }
ca = { vault = { address = "https://${data.terraform_remote_state.vault.outputs.vault_internal_ip}:8200" ca_bundle_file = "/etc/ssl/vault-ca.crt"
mount = vault_mount.ssh.path role = vault_ssh_secret_backend_role.gateway.name
auth = { gcp = { type = "gce" mount = vault_auth_backend.gcp.path role = vault_gcp_auth_backend_role.gateway.role } } } }
resources = [twingate_ssh_resource.ssh_server] }}Key fields in the ssh.ca.vault block:
address— the Vault server URL reachable from the Gateway VMca_bundle_file— path on the Gateway VM to the CA certificate used to verify Vault’s TLS certificatemount— the SSH secrets engine mount path (default:ssh)role— the signing role that defines certificate parameters (TTL, allowed users, extensions)auth.gcp— configures GCP auth with thetype(gceoriam), auth backendmountpath, androlename
The SSH CA private key no longer needs to be written to the Gateway VM. The Gateway authenticates to Vault at runtime using its GCE instance identity and requests signed certificates on demand.
Configure the SSH server
In the base guide, the SSH server startup script writes a static CA public key and configures TrustedUserCAKeys. With Vault, the SSH server also authenticates to Vault to obtain a signed host certificate, which eliminates TOFU warnings for the Gateway’s connection to the SSH server.
#!/bin/bashset -euo pipefail
apt-get update -qq && apt-get install -y -qq jq
# Create the gateway useruseradd -m -s /bin/bash gateway
# Authenticate to Vault via GCP authJWT=$(curl -sf -H "Metadata-Flavor: Google" \ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=vault/${vault_gcp_role}&format=full")
LOGIN_RESPONSE=$(curl -s --fail-with-body \ --cacert /etc/ssl/vault-ca.crt \ -X POST \ --data "{\"role\": \"${vault_gcp_role}\", \"jwt\": \"$JWT\"}" \ "${vault_addr}/v1/auth/${vault_gcp_mount}/login")
VAULT_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.auth.client_token')
# Sign the host key with the Vault SSH CAHOST_PUB_KEY=$(cat /etc/ssh/ssh_host_ed25519_key.pub)
RESPONSE=$(curl -s --fail-with-body \ --cacert /etc/ssl/vault-ca.crt \ -H "X-Vault-Token: $VAULT_TOKEN" \ -X POST \ --data "{\"public_key\": \"$HOST_PUB_KEY\", \"cert_type\": \"host\", \"ttl\": \"8760h\"}" \ "${vault_addr}/v1/${vault_mount}/sign/${vault_role}")
echo "$RESPONSE" | jq -r '.data.signed_key' > /etc/ssh/ssh_host_ed25519_key-cert.pub
# Configure sshd to trust CA and present host certificateecho "TrustedUserCAKeys /etc/ssh/vault-ssh-ca.pub" >> /etc/ssh/sshd_configecho "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub" >> /etc/ssh/sshd_config
systemctl restart sshdThe ${vault_*} placeholders are filled by templatefile() when the SSH server instance is created. The script authenticates to Vault using the VM’s GCP identity token, then requests a signed host certificate for the server’s ED25519 key.
Deploy
The Vault setup is a two-step deployment because Vault must be running before the root module can configure its secrets engines and issue certificates.
Step 1: Deploy Vault infrastructure
The vault/ subdirectory provisions the VPC, subnet, Cloud NAT, and Vault VM:
cd vault/cp terraform.tfvars.example terraform.tfvars# Edit terraform.tfvars with your GCP project ID
terraform initterraform applyAfter Vault starts (approximately 60 seconds), retrieve the root token:
gcloud compute ssh demo-vault-server --zone us-central1-a --tunnel-through-iap -- \ "sudo cat /opt/vault/init-output.json"Step 2: Deploy the root module
Start an IAP tunnel to Vault in a separate terminal:
gcloud compute start-iap-tunnel demo-vault-server 8200 \ --local-host-port=localhost:8200 --zone=us-central1-aThen deploy the Gateway, Connector, and SSH server:
cd ..cp terraform.tfvars.example terraform.tfvars# Edit terraform.tfvars with your credentials and the vault_token from step 1
terraform initterraform applyThe Vault root token is stored unencrypted in the Terraform state. Use a remote backend with encryption to protect sensitive state data. For production, configure Vault with KMS-based auto-unseal and distribute unseal keys separately.
After Terraform completes, verify the connection using the same steps as in the base guide.
Clean up
Remove all resources in reverse order:
terraform destroycd vault && terraform destroyNext steps
- Getting Started with IDFW SSH using Terraform — the base guide this document builds on
- Privileged Access for SSH overview — full feature documentation including supported protocols and security considerations
- Remote development with Twingate SSH — set up VS Code, JetBrains, and Cursor for remote development
- SSH session recording for compliance — configure session capture and SIEM forwarding
- Full Terraform example — the complete, runnable Terraform configuration this guide is based on
Join us in the community subreddit to share your setup experience or ask questions.
Last updated 1 day ago