Understanding OpenSSH Certificates and GitHub Actions OIDC
Every few months we find another static SSH private key sitting in a repository secret or baked into a base AMI. Someone created it three years ago, labeled it DEPLOY_KEY_PROD_FINAL_v2, and gave it root access to half our fleet because debugging an automation failure at 2 a.m. was easier that way. Nobody rotates it because nobody knows which pipelines will break if they delete it from authorized_keys.
Static keys in CI/CD are liabilities waiting to detonate. If a runner gets compromised, or an arbitrary code execution bug pops up in a dependencies build step, those long-lived credentials walk out the door. We wanted zero static deploy keys on runners, zero runner-specific public keys scattered across target servers—much like broader efforts to replace shared SSH keys—and a cryptographically verifiable identity trail tied to our pull requests and branches.
The standard way to kill static keys on hosts is OpenSSH certificates. Unlike standard asymmetric key pairs where the server must explicitly store your public key in ~/.ssh/authorized_keys, OpenSSH certificates rely on a Certificate Authority (CA)—an approach detailed in our guide to implementing SSH certificate authorities. The host trusts the CA's public key once. When a client presents an ephemeral public key accompanied by a signature from that CA, the host validates the signature, checks that the certificate is currently valid, and verifies the requested login account matches an approved principal embedded in the certificate. No host-side state cleanup required.
GitHub Actions natively exposes an OpenID Connect (OIDC) identity provider. When a job runs with id-token: write permissions, GitHub mints an ephemeral JSON Web Token (JWT) signed by GitHub's OIDC issuer (https://token.actions.githubusercontent.com). This token contains verifiable claims about the repository, run ID, git ref, workflow name, and environment. We can use this JWT to bridge the gap between GitHub's ephemeral identity and our internal SSH CA.
Architectural Blueprint: Ephemeral SSH Access via OIDC Federation
The flow relies on an intermediary that can validate GitHub's OIDC JWT and sign an SSH user certificate on the fly. We use HashiCorp Vault's SSH secrets engine alongside its JWT auth method for this bridge.
# The exchange lifecycle:1. Runner requests OIDC JWT from GitHub's internal runtime API
2. Runner generates a fresh, disposable ed25519 keypair in memory/tmpfs
3. Runner posts JWT + ephemeral public key to Vault (/v1/auth/jwt/login -> /v1/ssh-client-signer/sign)
4. Vault verifies JWT signature against token.actions.githubusercontent.com
5. Vault checks claims (repo == 'org/repo', ref == 'refs/heads/main')
6. Vault returns signed ed25519-cert.pub with a 5-minute TTL
7. Runner connects to target server using cert
8. Target server's sshd validates signature against local ssh_ca.pub
Target hosts never communicate with GitHub, nor do they communicate with Vault during connection negotiation. They simply evaluate the signature and constraints baked into the certificate using their locally configured CA public key.
Prerequisites and Infrastructure Configuration
Setting Up the OpenSSH Certificate Authority
If you don't already have a dedicated CA keypair, generate an isolated Ed25519 key on an offline or secured host. In production, this private key resides inside Vault or an HSM; never keep it unencrypted on a development laptop.
$ ssh-keygen -t ed25519 -f ssh_user_ca -C "production-deploy-ca"
Generating public/private ed25519 key pair. Enter passphrase (empty for no passphrase): [PROTECTED] Enter same passphrase again: [PROTECTED] Your identification has been saved in ssh_user_ca Your public key has been saved in ssh_user_ca.pub
Configuring Target Linux Hosts
Copy ssh_user_ca.pub to your target hosts (for instance, via your base image or configuration management tool) to /etc/ssh/ssh_user_ca.pub. Then tell the SSH daemon to trust user certificates signed by this key.
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/ssh_user_ca.pub AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
We configure AuthorizedPrincipalsFile to isolate which certificates can log into which system users. If the runner attempts to log into the deploy user, sshd inspects /etc/ssh/auth_principals/deploy:
# /etc/ssh/auth_principals/deploy
github-actions-deployer
Reload the daemon once applied:
# sshd -t && systemctl reload sshd
Configuring Vault as the OIDC-Aware Signing Intermediary
Mount the JWT auth engine and configure it to trust GitHub's OIDC issuer:
$ vault auth enable jwt
$ vault write auth/jwt/config \ oidc_discovery_url="https://token.actions.githubusercontent.com" \ bound_issuer="https://token.actions.githubusercontent.com"
Now mount the SSH secrets engine and set up the CA key:
$ vault secrets enable -path=ssh-client-signer sshUpload the CA private key generated earlier (or let Vault generate its own internal CA)
$ vault write ssh-client-signer/config/ca \ private_key=@ssh_user_ca \ public_key=@ssh_user_ca.pub
Create a Vault role that restricts the certificate issuance parameters. We restrict the principal to github-actions-deployer and enforce a 300-second maximum TTL.
$ vault write ssh-client-signer/roles/deployer \
key_type=ca \ algorithm_signer=ed25519 \ allow_user_certificates=true \ allowed_users="github-actions-deployer" \ default_user="github-actions-deployer" \ ttl=300s \ max_ttl=300s \ allow_user_key_ids=true
Bind the JWT auth path to this role using bound claims, locking access to a specific repository and branch:
$ vault write auth/jwt/role/gh-actions-deploy \
role_type="jwt" \ bound_audiences="https://vault.internal.net" \ user_claim="sub" \ bound_claims_type="glob" \ bound_claims='{ "repository": "our-org/production-service", "ref": "refs/heads/main" }' \ policies="deployer-policy" \ ttl=5m
Where deployer-policy allows write access to ssh-client-signer/sign/deployer.
Step-by-Step: Implementing the GitHub Actions Workflow
Enabling Permissions and Passing the OIDC JWT
The workflow file requires the id-token: write permission block. Without this, GitHub's internal token broker rejects requests for OIDC credentials.
name: Deploy to Productionon: push: branches: - main
permissions: contents: read id-token: write
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4
- name: Generate Ephemeral Key & Sign via Vault id: ssh-cert env: VAULT_ADDR: "https://vault.internal.net" run: | set -euo pipefail
# 1. Obtain OIDC JWT from GitHub's runtime API AUDIENCE="https://vault.internal.net" OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}" | jq -r '.value')
# 2. Log in to Vault using the OIDC token VAULT_TOKEN=$(curl -sS --request POST \ --data "{\"role\": \"gh-actions-deploy\", \"jwt\": \"$OIDC_TOKEN\"}" \ "${VAULT_ADDR}/v1/auth/jwt/login" | jq -r '.auth.client_token')
# 3. Generate one-time local keypair mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keygen -t ed25519 -N "" -f ~/.ssh/ephemeral_deploy_key -C "gha-run-${GITHUB_RUN_ID}"
# 4. Request signed certificate from Vault PUBKEY=$(cat ~/.ssh/ephemeral_deploy_key.pub) RESP=$(curl -sS --header "X-Vault-Token: $VAULT_TOKEN" \ --request POST \ --data "{\"public_key\": \"$PUBKEY\", \"valid_principals\": \"github-actions-deployer\"}" \ "${VAULT_ADDR}/v1/ssh-client-signer/sign/deployer")
echo "$RESP" | jq -r '.data.signed_key' > ~/.ssh/ephemeral_deploy_key-cert.pub chmod 600 ~/.ssh/ephemeral_deploy_key-cert.pub
- name: Execute Deployment env: TARGET_HOST: "target-node.internal.net" run: | # SSH picks up ~/.ssh/ephemeral_deploy_key-cert.pub automatically # when the private key name matches ssh -i ~/.ssh/ephemeral_deploy_key \ -o StrictHostKeyChecking=accept-new \ -o IdentitiesOnly=yes \ deploy@${TARGET_HOST} \ "echo 'Connected via ephemeral certificate' && uptime"
A Diagnostic Run and the Inevitable Failure
The first time I set this up, the SSH step failed immediately:
$ ssh -vvv -i ~/.ssh/ephemeral_deploy_key [email protected]
OpenSSH_8.9p1 Ubuntu-3ubuntu0.6, OpenSSL 3.0.2 15 Mar 2022 debug1: Authenticating to 10.20.0.15:22 as 'deploy' debug1: Certificate invalid: name is not a listed principal debug1: No more authentication methods to try. [email protected]: Permission denied (publickey).
To see what the server was rejecting, I pulled the certificate contents directly on the runner:
$ ssh-keygen -Lf ~/.ssh/ephemeral_deploy_key-cert.pub
~/.ssh/ephemeral_deploy_key-cert.pub: Type: [email protected] user certificate Public key: ED25519-CERT SHA256:4tT... Signing CA: ED25519 SHA256:jK3... (ssh-user-ca) Key ID: "vault-gh-actions-deploy-1711718000" Serial: 10459203949823485000 Valid: from 2024-03-29T13:10:00 to 2024-03-29T13:15:00 Principals: deployer Critical Options: (none) Extensions: permit-X11-forwarding permit-agent-forwarding permit-port-forwarding permit-pty permit-user-rc
The mistake: I passed deployer as the requested principal during the Vault sign request, but target hosts had github-actions-deployer in their /etc/ssh/auth_principals/deploy mapping file. OpenSSH matched deployer against the file, saw no match, and denied access outright.
Once I aligned the principal in the workflow curl call to github-actions-deployer, the handshake succeeded without prompts.
Security Hardening and Claim Validation
Federating access via OIDC without strict claim validation is a disaster in the making. If your Vault JWT role only checks that the token comes from GitHub, any public repository on GitHub could sign a token against your issuer and exchange it for a valid certificate into your fleet.
Scoping Claims
Always pin specific repository names, branches, and environments using bound_claims:
| Claim | Example Value | Why It Must Be Constrained |
|---|---|---|
repository |
my-org/core-infra |
Prevents fork execution or foreign repos from hitting your auth mount. |
ref |
refs/heads/main |
Stops arbitrary pull requests or branch runs from issuing certificates. |
environment |
production |
Enforces GitHub environment protection rules (approvals, manual gates). |
aud |
https://vault.internal.net |
Prevents token reuse against different service endpoints. |
Micro-Lifespans and Sudo Isolation
Our certificates default to a 5-minute (300s) window. Once the workflow step completes, the private key is wiped when the container exits, and the public certificate becomes invalid on any SSH server even if the logs or artifacts leaked it.
On the target host, the deploy user should not have passwordless ALL=(ALL) NOPASSWD: ALL rights. Drop a specific file into /etc/sudoers.d/deploy that limits it to restarting the app service or running the specific deploy script:
# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart my-service, /usr/local/bin/deploy-app
Troubleshooting Common Pitfalls
Audience (aud) Claim Mismatch
If you see error: token contains invalid audience claim during Vault authentication, check your curl call in the action. GitHub defaults the audience to the repository owner if omitted. You must pass &audience=<VAULT_AUDIENCE> matching the Vault role configuration.
Clock Drift and "Certificate not yet valid"
If your runner's system clock is behind the signing CA's clock, OpenSSH fails immediately with debug1: Certificate invalid: not yet valid. Vault supports valid_after backdating (e.g., subtracting 30 seconds from the current time) to absorb minor clock skew between the signing system and runner environments.
Target Host StrictHostKeyChecking
GitHub Actions runners don't know your target servers' host keys. Using StrictHostKeyChecking=no makes you vulnerable to machine-in-the-middle attacks. Following standard OpenSSH security recommendations, the proper solution is to use host certificates as well: sign the server's /etc/ssh/ssh_host_ed25519_key.pub with a Host CA, and add @cert-authority *.internal.net <host_ca.pub> to /etc/ssh/ssh_known_hosts on the runner before invoking SSH.
Open Questions and Next Experiments
We avoided installing the full Vault CLI on the runner to keep image boot overhead minimal, but parsing JSON tokens manually in bash scripts via curl and jq is brittle. If HashiCorp changes the response schema or curl fails quietly, debugging CI runs is painful.
Next thing to look at: Can we ditch network calls to an intermediate service like Vault entirely by running a small, self-hosted signing daemon that listens purely on an AWS Lambda / Cloudflare Worker, checks GitHub OIDC JWKS keys directly, and returns the signed OpenSSH cert in one hop? That would take one piece of infrastructure out of the deployment loop.
