Ephemeral SSH Access: OpenSSH Certificates, OIDC Exchange, and Kernel-Level Attribution
Last verified2026-10 Also see: Auth deep dive (OAuth, OIDC, JWT internals) · Workload identity federation (the same OIDC exchange for machines) · Linux hardening (sshd baseline) · Build platform security (SSH into CI jobs) · Sandbox isolation (the hosts you are logging into)
Start here: the basics in plain words
Normal SSH works like a building where every door has its own list of keys taped to it. The list is the authorized_keys file on each server; your laptop's public key must be on that list before you can enter. This works for five servers. It fails for five thousand, because every hire, every leaver and every lost laptop means editing lists on every door, and nobody ever finishes removing old keys.
An SSH certificate replaces the taped-up lists with a badge system. One trusted office (the certificate authority, CA) signs a short-lived badge that says "this key belongs to alice, she may log in as deploy, and the badge expires in 15 minutes". Every server is told once: "trust badges signed by this office". After that, nobody edits the doors again. New hire? Issue a badge. Leaver? Stop issuing badges; the last one expires by itself before lunch.
The same trick works in the other direction. A host certificate is a badge the server shows you, signed by a second office (the host CA). That removes the Are you sure you want to continue connecting (yes/no)? question, which is a security control that almost everyone answers blindly.
A per-server list of public keys allowed to log in. Standing access: it works until someone remembers to delete it.
Your public key plus identity, allowed usernames, an expiry and restrictions, all signed by a user CA. Access that ends by itself.
The server's host key, signed by a host CA, so your client can verify the server without a prior visit.
Trust On First Use. The first time you connect, SSH remembers the host key and hopes it was the real server. Host certificates remove the hope.
OpenID Connect. The login protocol behind "Sign in with your company account". It produces a signed token that says who you are; the SSH CA can turn that token into a badge.
Key Revocation List. OpenSSH's compact "these serials are dead" file, for when 15 minutes is still too long.
The end-to-end picture is three parties and two signatures:
-
1. Engineer proves who they arebrowserOIDC login, MFA
- Browser or CLI signs in with the company identity provider; the result is a signed ID token naming the person and their groups
-
2. CA service checks the token and signs a certificateserver~15 minutes validity
- Verifies the token's signature, issuer, audience and expiry; maps groups to allowed Unix usernames; signs the engineer's public key
-
3. Client connects, presents the certificatetlsnormal SSH handshake
- The server checks the CA signature, the validity window, the principals and the critical options; no authorized_keys lookup
-
4. Audit traildefendwho, as which user, which serial, which commands
- sshd logs the key ID and serial; the kernel (auditd or eBPF) records what the session actually executed
Memory hook"badge, not key list." A key list is checked where you enter and edited by hand. A badge is issued centrally, expires on its own and carries its own rules. Every design decision on this page is a consequence of moving the decision from the door to the badge office.
Static Keys vs OpenSSH Certificates
What is this?
OpenSSH has supported its own certificate format since version 5.4 (2010). It is deliberately not X.509: there is no certificate chain, no ASN.1 parser, no extension explosion. A certificate is a public key with a handful of fields bolted on and one signature from a CA key. That small design is the point: the parser on the server side is tiny, and the trust model is "one flat list of CA keys", not "a forest of intermediates".
Why it matters
Interviewers use this topic to see whether you can design access that ends by itself. Standing access is the root of most lateral movement stories: a key issued three years ago to a contractor who left, still valid on a bastion. Certificates convert "remember to revoke" into "default deny after expiry", and they move the authorisation decision to a place where you can put MFA, group lookups and logging in front of it.
How it works
What authorized_keys cannot do, and what a certificate does instead:
A key in authorized_keys is valid until deleted. A certificate carries valid before; sshd refuses it one second later with no network call.
A key has a free-text comment nobody checks. A certificate has a signed key ID (alice@example.com) that appears verbatim in the server log.
A key is authorised for whichever Unix account's file it sits in. A certificate lists principals: the account names it may log in as. One badge can allow alice and deploy, nothing else.
Options like from= or command= in authorized_keys have to be copied to every server. In a certificate, source-address and force-command are inside the signed blob and enforced everywhere.
Deleting a key means touching every file it was in. A certificate has a serial; one line in a KRL kills it on every server that reads the KRL.
There is no authorized_keys equivalent for host keys at all; TOFU is the whole mechanism. Host certificates are the only clean fix.
In practiceA real user certificate, signed with an Ed25519 CA and inspected with ssh-keygen -L. Everything an interviewer could ask about is visible in one screen:
$ ssh-keygen -s user_ca -I 'alice@example.com oidc-sid=8f3c21' -n alice,deploy \
-V +15m -z 4821 -O source-address=10.20.0.0/16 \
-O no-port-forwarding -O no-agent-forwarding -O no-x11-forwarding alice.pub
$ ssh-keygen -L -f alice-cert.pub
alice-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:j3YqN8lMKUChuFGkRRxlqesoYJQWqytgrpFGTZfqW5M
Signing CA: ED25519 SHA256:+zFWDBrnF5v2VYVCy5/v12om26Y2UQJ4qOnYVQbfBzs (using ssh-ed25519)
Key ID: "alice@example.com oidc-sid=8f3c21" ← who; goes into the server log verbatim
Serial: 4821 ← what the KRL revokes by
Valid: from 2026-10-24T21:30:00 to 2026-10-24T21:46:13 ← 15 minutes, plus a minute of clock-skew slack
Principals:
alice ← may log in as these Unix accounts
deploy
Critical Options:
source-address 10.20.0.0/16 ← refused from any other source IP
Extensions:
permit-pty ← note what is missing: no agent/port/X11 forwarding
permit-user-rc🎯On the job. When you inherit a fleet, the first audit is not "are keys strong" but "where do keys live and when were they last used".
find / -name authorized_keys 2>/dev/nullon a sample of hosts, thenssh-keygen -lfeach entry and match fingerprints to people. Keys you cannot attribute are the migration's first casualties. The target state isAuthorizedKeysFile noneon every host andTrustedUserCAKeyspointing at one file.
📰Real incident — Matrix.org (2019). Attackers compromised a Jenkins server, then reached production hosts because an engineer's SSH agent was forwarded into the compromised box; the agent happily signed challenges for any host the attacker pointed it at. With certificates, the same engineer's badge would have lacked
permit-agent-forwardingby default and would have expired before the pivot. The lesson: restrictions you must remember to apply per host do not scale, and agent forwarding is a loaded weapon left on the table.
OpenSSH Certificate Internals
What is this?
The format is defined in the OpenSSH source tree file PROTOCOL.certkeys. A certificate is an SSH public key blob whose type name ends in -cert-v01@openssh.com, followed by metadata, followed by the CA's signature over everything before it.
Why it matters
The fields are the contract. Every question about "can a badge be misused for X" is answered by one field: can it be replayed (nonce, validity), can it be used by the wrong person (principals), from the wrong place (source-address), for the wrong thing (force-command, extensions), after compromise (serial plus KRL).
How it works
- type name
ssh-ed25519-cert-v01@openssh.com; tells the parser which key algorithm follows- nonce
- Random bytes the CA inserts so an attacker cannot predict the signed message and attempt a collision attack on the CA
- public key
- The holder's actual public key; for Ed25519 a 32-byte point
- serial
- CA-chosen number; the handle used for revocation. 0 means "no serial"
- type
- 1 = user certificate, 2 = host certificate. A user cert can never authenticate a host or vice versa
- key id
- Free-text identity; logged by sshd. Put the human or workload identity here, never a shared label
- valid principals
- Usernames (user cert) or hostnames/IPs (host cert) this certificate is valid for. Empty means "any", which TrustedUserCAKeys refuses
- valid after / before
- Unix seconds. The window is checked against the server's clock, so NTP is part of your security boundary
- critical options
- Rules the server must understand or refuse the cert:
force-command,source-address,verify-required - extensions
- Permissions the server may ignore if unknown:
permit-pty,permit-port-forwarding,permit-agent-forwarding,permit-X11-forwarding,permit-user-rc,no-touch-required - reserved
- Empty; kept for future use
- signature key
- The CA's public key. The server checks it is in TrustedUserCAKeys (or a known_hosts @cert-authority line)
- signature
- The CA's signature over every field above
Two rules in that layout carry most of the security weight:
If a CA puts source-address in a cert and the server's sshd is too old to understand it, the server rejects the certificate rather than silently dropping the restriction. Extensions are the opposite: unknown ones are ignored. That asymmetry is why restrictions go in critical options and permissions go in extensions.
When TrustedUserCAKeys is in use, a certificate with no principals is rejected outright. Without an AuthorizedPrincipalsFile, the Unix username being logged into must appear in the list. The CA cannot issue a "root anywhere" badge by accident unless it literally writes root into principals.
In practiceThe three critical options and what each one is for:
force-command=/usr/local/bin/backup-pull ← the only command this cert can run; the user's requested command is put in SSH_ORIGINAL_COMMAND
source-address=10.20.0.0/16,2001:db8::/32 ← refused unless the TCP connection comes from these ranges
verify-required ← for FIDO (sk-) keys: the hardware token must verify the user (PIN or biometric), not just presence🎯On the job. Review CA issuance code the way you would review an IAM policy. Red flags:
-n(principals) derived from a user-supplied field without allow-listing, validity longer than the task (a day for a human is already long, an hour for a CI job is long), key IDs that are not unique per person, and extensions copied from a default that includes agent forwarding. The CA is the policy decision point; sshd only enforces what the CA wrote.
Identity Federation and CA Architecture
What is this?
The CA is a small service that answers one question: "given this proof of identity, which certificate may I sign?" The proof is usually an OIDC ID token from the company identity provider for humans, or a workload identity token (GitHub Actions, Buildkite, cloud instance identity) for machines. The CA verifies the token, applies policy, and signs the caller's public key.
Why it matters
This is where the real authorisation lives, and where the interview goes deep: replay, races, token binding, and what happens when the CA key leaks. Getting the SSH part right and the OIDC part wrong gives you a very fast way to issue badges to the wrong person.
How it works
The human flow, as a sequence:
sequenceDiagram participant U as Engineer CLI participant I as Identity provider participant C as SSH CA service participant S as Target host U->>I: Authorization code + PKCE<br/>(browser, MFA) I-->>U: Code U->>C: Code + verifier + ssh public key C->>I: Redeem code I-->>C: ID token (iss, sub, aud, groups, exp) C->>C: Verify sig, aud, nonce, jti<br/>map groups → principals C-->>U: Certificate, 15 min U->>S: SSH with cert S-->>U: Session, logged with key ID + serial
Design points that separate a safe CA from a demo:
If the CLI obtains the ID token itself and posts it to the CA, the token is a bearer secret in transit and in the user's process memory. Have the CLI start the login, but let the CA redeem the authorization code (with PKCE, as in the auth deep dive) so the ID token never leaves the CA.
The request carries the public key to sign. Put a hash of that public key into the OIDC nonce (or state) when starting the login, and check it when the token comes back. A token stolen from one request cannot be used to get a certificate for an attacker's key.
Record the token's jti (or a hash of the token) in a short-lived store and refuse a second use within its lifetime. That closes the replay window even if a token leaks from logs.
Register the CA as its own OIDC client so aud is the CA's client ID. A token minted for the wiki must not be exchangeable for SSH.
A table mapping identity-provider groups to principals, maximum TTL and allowed extensions. sre-oncall maps to deploy on tag:prod hosts for 15 minutes; developers map to their own username on dev hosts for 8 hours.
If badges live 15 minutes, the KRL is for emergencies, not for leavers.
In practiceThe identity-provider claims the CA actually reads, and what each becomes in the certificate:
{
"iss": "https://login.example.com", ← must equal the configured issuer exactly; no prefix matching
"aud": "ssh-ca-prod", ← the CA's own client ID
"sub": "00u1f2k3l4m5", ← stable user ID; the thing you key audit logs on
"email": "alice@example.com", ← goes into the certificate key ID for humans reading logs
"groups": ["sre-oncall", "developers"], ← mapped through the policy table to principals and TTL
"amr": ["mfa", "hwk"], ← refuse certificates for prod principals unless MFA is present
"nonce": "sha256:4f1e…", ← hash of the SSH public key submitted with this login
"exp": 1761341700,
"jti": "a3f9c1…" ← stored for its lifetime; a second request with the same jti is denied
}Where the CA private key lives decides the blast radius:
Keep a root user CA offline and sign one intermediate per environment or region with a limited lifetime. Servers trust the intermediates' public keys. Rotating an intermediate is a file push; the root never touches the network. (OpenSSH has no chains: "intermediate" here just means "another CA key the servers trust", signed out-of-band by the root for your own bookkeeping.)
The CA process never holds raw key bytes; it asks a KMS or HSM to sign the certificate blob. A compromised CA host can mint badges while it is compromised, but cannot walk away with the key.
Every certificate signed, with serial, key ID, principals and the identity claims that justified it, shipped to a log the CA cannot edit. This is the list you consult when revoking.
📰Real incident — CircleCI (2023). Malware on an engineer's laptop stole a single sign-on session cookie that had already passed MFA, and the attacker used that session to reach production systems and customer secrets. For an SSH CA the same lesson applies twice: a stolen identity-provider session can request certificates, so bind issuance to device posture and fresh MFA for privileged principals, and keep TTLs short so a stolen certificate is worth minutes, not weeks.
🎯On the job. Ask of any CA you review: can I replay a captured ID token? (Needs
jtitracking.) Can I use my token to get a cert for someone else's key? (Needs key binding.) Can a token for another app be used here? (Needs strictaud.) What happens when the identity provider's signing key rotates? (Needs JWKS refresh withkidlookup, see the workload identity page.) If any answer is "yes" or "we didn't think about it", that is your finding.
Target Host Enforcement
What is this?
The server-side configuration that makes sshd accept certificates, decide which principals are allowed for which Unix account, present its own host certificate, and refuse revoked serials. It is four or five lines of sshd_config, each with a sharp edge.
Why it matters
A CA that issues perfect certificates is useless if one host still accepts authorized_keys, and dangerous if the principal mapping lets alice become root. The host is where the policy is enforced, so the host is where an attacker who cannot get a certificate will look for a gap.
How it works
# /etc/ssh/sshd_config (relevant lines only)
TrustedUserCAKeys /etc/ssh/user_ca_keys.pub ← one or more CA public keys; a cert signed by any of them is a candidate
AuthorizedPrincipalsFile /etc/ssh/principals/%u ← per-account list of principal names allowed to log in as %u
RevokedKeys /etc/ssh/revoked.krl ← KRL; if this file is unreadable, ALL public-key auth is refused
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub ← served to clients alongside the host key
AuthorizedKeysFile none ← the migration is not done until this line exists
CASignatureAlgorithms ssh-ed25519,rsa-sha2-512 ← refuse CAs that sign with weaker algorithms
PermitRootLogin noThree ways to decide "which principal may become which user", from simplest to most flexible:
The default. The certificate must list the Unix username itself. Simple, but it means the CA has to know every account name on every host.
AuthorizedPrincipalsFile /etc/ssh/principals/%uEach account has a file listing roles that may become it. /etc/ssh/principals/deploy containing sre-oncall and release-bot means a certificate with principal sre-oncall may log in as deploy. The CA now issues role badges, and hosts decide locally which roles map to which accounts. The file may also carry per-line options such as command= or from=.
AuthorizedPrincipalsCommandRun a program (as AuthorizedPrincipalsCommandUser, a dedicated unprivileged account) that prints allowed principals for this login, with tokens such as %u (user), %i (certificate key ID), %s (serial) and %k (the key). This is how you query a directory or an inventory service at login time, for example "is host tag prod currently in a change window for this principal?" sshd refuses to start if the command is set without a dedicated user.
On the client side, host certificates need one line that replaces thousands of known_hosts entries:
# /etc/ssh/ssh_known_hosts (fleet-wide, shipped by config management)
@cert-authority *.rack3.example.internal,10.20.0.0/16 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMq… host-ca@infraIn practiceWhat a successful certificate login looks like in the server log. The key ID and serial are the audit trail; the fingerprint of the CA tells you which CA was trusted:
$ journalctl -u ssh -o cat | grep Accepted | tail -1
Accepted publickey for deploy from 10.20.1.54 port 51822 ssh2: ED25519-CERT SHA256:j3YqN8lMKUChuFGkRRxlqesoYJQWqytgrpFGTZfqW5M ID alice@example.com oidc-sid=8f3c21 (serial 4821) CA ED25519 SHA256:+zFWDBrnF5v2VYVCy5/v12om26Y2UQJ4qOnYVQbfBzs
↑ Unix account ↑ who (from the cert) ↑ revoke by this ↑ which CAAnd the failure you will debug most often, which is almost always a principal or clock problem rather than a crypto one:
$ ssh -v deploy@build-07 2>&1 | grep -iE 'cert|principal|expired'
debug1: Offering public key: alice ED25519-CERT SHA256:j3Yq… agent
debug1: Server accepts key: alice ED25519-CERT SHA256:j3Yq… agent ← sshd liked the CA signature
Permission denied (publickey).
# on the server:
error: Certificate invalid: name is not a listed principal ← cert has alice,deploy; principals/deploy does not list either
# or:
error: Certificate invalid: expired ← check `timedatectl` on both ends before anything elsemacOS hosts (including Apple Silicon virtual machines started with the Virtualization framework) ship the same OpenSSH and honour the same directives; the differences are where the config lives (/etc/ssh/sshd_config.d/ is read on both) and that Remote Login must be enabled in System Settings or via systemsetup -setremotelogin on.
🎯On the job.
sudo sshd -T | grep -iE 'trusteduserca|authorizedprincipals|revokedkeys|authorizedkeysfile|hostcertificate'shows what sshd actually loaded, which is not always what the file says (a typo in anIncludeor aMatchblock silently wins). CheckRevokedKeyspoints at a file that exists and is readable by root, because an unreadable KRL locks everyone out, including the people who could fix it.
Bare-Metal and Multi-Arch Fleets: Killing TOFU
What is this?
Every newly provisioned machine, whether an AMD EPYC rack server, an AmpereOne ARM node or a macOS guest on an M4 or M5 Max host, generates a fresh host key at first boot. Without host certificates, every first connection to each of them is a TOFU event, and automation that connects to hundreds of them per hour either disables host key checking or breaks.
Why it matters
StrictHostKeyChecking no in a deployment script is one of the most common findings in infrastructure reviews, and it exists because TOFU does not fit ephemeral fleets. A host CA fixes the root cause instead of silencing the warning.
How it works
-
1. Node boots for the first timeosbare metal or vz guest
- Generates host keys; has an attested identity (TPM quote, cloud instance identity document, or a one-time provisioning token injected at image build)
-
2. Node asks the host CA for a certificateserveridentity document → cert
- Host CA verifies the attestation, checks the requested hostname matches inventory, signs the host key with principals = FQDN and IPs, validity days to weeks
-
3. sshd serves the host certificatetlsHostCertificate
- Clients that trust the host CA see no prompt; clients that do not still see a normal TOFU prompt, so nothing breaks
-
4. Renewal before expirydefendagent or timer
- A systemd timer (Linux) or launchd job (macOS) re-requests; a node that cannot renew falls out of the fleet, which is the correct failure
Hard parts, honestly:
On bare metal, a TPM attestation or a per-host provisioning secret baked by the imaging system. On AWS or GCP, the instance identity document, verified by its cloud signature. On macOS vz guests, the host that created the VM injects a one-time token through the virtio console or a shared directory at launch, and the host CA checks that token against the VM inventory. Without one of these, the host CA is signing whatever hostname it is told, which is TOFU with extra steps.
If engineers connect to build-07, build-07.rack3.example.internal and 10.20.3.7, the certificate needs all three or the client falls back to TOFU for the missing one.
Days, not years. A host that is reimaged keeps its name but gets a new key; with short validity the stale certificate is gone quickly even without a KRL.
In practiceA real host certificate and the client-side check that proves TOFU is gone:
$ ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub
Type: ssh-ed25519-cert-v01@openssh.com host certificate ← type 2; useless as a user cert
Key ID: "build-07.ampere.rack3"
Serial: 1007
Valid: from 2026-10-24T21:30:00 to 2027-10-23T21:31:13 ← this example is a year; production should be days
Principals:
build-07.ampere.rack3
10.20.3.7 ← connect by IP and it still verifies
$ ssh -v deploy@build-07.ampere.rack3 2>&1 | grep -E 'host certificate|Host .* known'
debug1: Server host certificate: ssh-ed25519-cert-v01@openssh.com SHA256:T174…, serial 1007 ID "build-07.ampere.rack3" CA ssh-ed25519 SHA256:uVY9… valid from 2026-10-24T21:30:00 to 2027-10-23T21:31:13
debug1: Host 'build-07.ampere.rack3' is known and matches the ED25519-CERT host certificate. ← no prompt, no known_hosts entry for this host📰Real incident — GitHub RSA host key exposure (2023). GitHub's RSA SSH host private key was briefly published in a public repository, and GitHub rotated it on 24 March 2023. Every client that had TOFU-pinned the old key saw
REMOTE HOST IDENTIFICATION HAS CHANGED, CI jobs using SSH checkouts failed, and millions of people ranssh-keygen -R github.comand accepted a new key with no way to verify it beyond a blog post. Ed25519 and ECDSA users were unaffected. The lesson for your own fleet: with a host CA, a key rotation is invisible to clients, and a rotation that is not invisible is a red flag rather than a Tuesday.
🎯On the job. Grep your automation for
StrictHostKeyChecking=no,UserKnownHostsFile=/dev/nullandssh-keyscanpiped intoknown_hostsat run time. Each one is a place where a machine-in-the-middle on the provisioning network would be accepted silently. The fix is a host CA plus one@cert-authorityline in the image.
Automated CI/CD Machine Access
What is this?
CI jobs need SSH too: to deploy to a host, to pull from a private Git server, to fetch an artifact from a bare-metal build cache. The anti-pattern is a long-lived deploy key stored in CI secrets. The pattern is the same CA, fed by the job's workload identity token instead of a human's OIDC login.
Why it matters
CI is where keys leak most: logs, caches, forks, compromised third-party actions. A deploy key that lives in a secrets store is one memory dump away from being on the internet (see the tj-actions incident on the workload identity page). A certificate minted per job for 10 minutes, usable only from the runner's egress IP and only for one command, is worth almost nothing to a thief.
How it works
-
1. Job requests an ID token from its CI platformserverno secret stored
- GitHub Actions:
id-token: writeand the runtime token endpoint; Buildkite:buildkite-agent oidc request-token --audience ssh-ca
- GitHub Actions:
-
2. Job generates a throwaway key pair and calls the CAtlskey never written to disk, kept in ssh-agent
- Request: ID token + public key; CA checks iss, aud, exp, then the claims that name the pipeline, branch and run
-
3. CA applies pipeline policydefendrepo/pipeline → principals, TTL, options
repo:acme/app:ref:refs/heads/main→ principalrelease-bot, 10 minutes,force-command=/usr/local/bin/deploy,source-address=<runner egress pool>
-
4. What a thief getsattackalmost nothing
- A cert bound to a key in the dead runner's memory, valid a few minutes, from an IP they do not have, for one command
The claims that should decide the certificate, per platform:
sub such as repo:acme/app:ref:refs/heads/main or repo:acme/app:environment:prod, plus repository_id and repository_owner_id so a renamed or re-created repository cannot inherit access, and job_workflow_ref so only the reviewed deploy workflow qualifies. Repositories created after 15 July 2026 use an immutable default subject that embeds the owner and repository IDs.
Issuer https://agent.buildkite.com; sub is organization:acme:pipeline:app:ref:refs/heads/main:commit:<sha>:step:deploy, with separate organization_slug, pipeline_slug, build_branch, step_key and runner_environment claims; tokens default to five minutes.
Never grant a principal based on the repository name alone. A fork's sub carries the fork owner, which is why it is safe, but a wildcard such as repo:acme/* lets any repository in the organisation, including a sandbox someone created last week, deploy to production.
In practiceThe CA's policy for a deploy pipeline, and the certificate it produces. Note that the certificate's key ID carries the run identifier, so a deploy on a host can be traced to one CI run in the server log:
# ssh-ca policy (data, reviewed like code)
- match:
iss: https://token.actions.githubusercontent.com
repository_id: "74213" # ← immutable; a renamed or re-created repo does not match
job_workflow_ref: "acme/app/.github/workflows/deploy.yml@refs/heads/main"
ref: refs/heads/main
issue:
principals: [release-bot]
ttl: 10m
key_id: "gha run_id={{run_id}} sha={{sha}}"
critical_options:
force-command: /usr/local/bin/deploy # ← the job cannot get a shell even if the workflow is edited
source-address: 203.0.113.0/28 # ← the runners' dedicated egress pool
extensions: [] # ← no pty, no forwarding of any kind# on the target host, later
Accepted publickey for release-bot from 203.0.113.9 port 40212 ssh2: ED25519-CERT SHA256:… ID gha run_id=18223019 sha=9f1c2e7 (serial 90311) CA ED25519 SHA256:+zFW…🎯On the job. Search CI configuration for
ssh-add,SSH_PRIVATE_KEY,id_rsaanddeploy_key. Each is a long-lived credential to migrate. While migrating, at least give each existing keyfrom=andcommand=restrictions inauthorized_keys, so the interim state is bounded. Then measure: the number of long-lived SSH secrets in the CI secret store should trend to zero, and the CA issuance log should show one short certificate per deploy.
Egress Filtering and Network Binding
What is this?
source-address ties a certificate to where the connection comes from. Combined with dedicated egress IP pools (runners or bastions that always leave the network through a known, small set of addresses) and an egress proxy that only lets approved destinations through, a certificate becomes useless outside the network path it was issued for.
Why it matters
TTL limits when a stolen badge works; source-address limits from where. An attacker who exfiltrates a certificate and its private key to their own machine has a badge that every sshd in the fleet refuses on the first packet.
How it works
Issue certificates with source-address set to the VPN or zero-trust proxy ranges. A certificate copied to a coffee-shop laptop fails even inside its 15 minutes.
Give each runner pool a NAT gateway or egress proxy with a small, stable address range, and put that range in the certificate. Multi-tenant runner platforms assign a pool per customer or per trust tier so one tenant's certificates cannot be used from another tenant's runners.
Put source-address on the bastion's addresses for production principals, and make the bastion the only thing allowed to reach port 22 on production (security groups or firewall rules). Now two unrelated controls, network and certificate, both have to fail.
The same proxy that pins where SSH may go can also forbid port 22 to anything but the inventory's hosts, which stops a compromised job using its certificate to probe the internal network.
In practiceWhat binding failure looks like, which is a useful alarm because it means a certificate is being used outside the path it was issued for:
# server log
Authentication tried for deploy with valid certificate but not from a permitted source address (198.51.100.7). ← alert on this string; it is never a normal user mistake
Connection closed by authenticating user deploy 198.51.100.7 port 55021 [preauth]🎯On the job.
not from a permitted source addressin sshd logs should page someone. Legitimate users on the VPN never trigger it; a copied certificate does. Pair it with the CA issuance log to see whose badge it was and which identity-provider session issued it.
Session Recording and Attribution
What is this?
Knowing who logged in is the certificate's job. Knowing what they did is the host's job, and user-space approaches (script, a recording bastion, a proxy that captures the terminal stream) only capture what the terminal shows. The reliable record comes from the kernel: every execve, every file open, every outbound connection, tagged with the session that caused it.
Why it matters
Terminal recordings are trivially evaded and interviewers know it. The question "why is TTY logging insufficient" is really "do you understand the difference between what the user sees and what the kernel does".
How it works
Why the terminal stream lies:
stty -echo means typed commands never appear in the recording; the output still does, but not the command that produced it.
echo Y3VybCBodHRwOi8vLi4u | base64 -d | sh records as a base64 blob. The recording is complete and useless.
ssh host 'command', SFTP and port forwarding have no TTY at all; a TTY recorder sees nothing.
A script downloaded and executed, a cron job planted for later, a process that forks and detaches. The terminal shows one line; the kernel sees dozens of processes.
What the kernel sees instead:
-
1. sshd accepts the certificateosPAM session opens
- pam_loginuid sets the audit login UID (auid) for the session; it is inherited by every child and survives
suandsudo, so a root shell still carries the original login
- pam_loginuid sets the audit login UID (auid) for the session; it is inherited by every child and survives
-
2. Session placed in its own cgroupossystemd session scope
session-42.scope; every process the user starts, however deeply forked, has this cgroup ID
-
3. eBPF programs attached to tracepointsdefendexecve, openat, connect
- Each event carries pid, cgroup ID, auid; a user-space collector maps cgroup → sshd session → certificate key ID and serial
-
4. Events shipped off-host immediatelydefendappend-only
- The attacker's
history -candrmof local logs change nothing
- The attacker's
Why eBPF rather than only auditd: auditd gives you the same execve facts and is the right baseline (see Linux hardening), but it is noisy at fleet scale and does not easily attach the session to each event. A small eBPF program on the sched_process_exec tracepoint, keyed by cgroup ID, produces exactly one record per command with the session attached, at a cost of microseconds. Commercial access proxies implement this pattern, and you can build a minimal version with bpftrace in a few lines.
Unique OS credentials complete the chain. If everyone logs in as deploy, the kernel's UID says deploy and attribution depends entirely on the sshd log line. If each person gets their own Unix account (created on demand from the certificate's principal by an AuthorizedPrincipalsCommand plus a PAM module, or pre-provisioned from the directory), then UID, auid, file ownership and sudo logs all say alice, and the shared role account disappears.
In practiceA minimal eBPF trace of what a session really executed, versus what the terminal recording showed. The terminal saw one obfuscated line; the kernel saw the real commands:
$ sudo bpftrace -e 'tracepoint:sched:sched_process_exec /cgroup == cgroupid("/user.slice/user-1105.slice/session-42.scope")/ { printf("%s auid=%d %s\n", strftime("%H:%M:%S", nsecs), uid, str(args->filename)); }'
21:33:02 auid=1105 /usr/bin/base64 ← the decode step
21:33:02 auid=1105 /usr/bin/sh ← fed to a shell
21:33:02 auid=1105 /usr/bin/curl ← what the base64 actually contained
21:33:03 auid=1105 /usr/bin/chmod
21:33:03 auid=1105 /tmp/.x/agent ← and what it ran afterwards; none of this was visible in the TTY recording🎯On the job. Check
grep pam_loginuid /etc/pam.d/sshd(without it,auidis unset and your audit trail starts as "unknown"), confirmauditctl -lincludes-a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=unset, and look for a collector that correlates by session or cgroup rather than only by UID. If the answer to "what did serial 4821 run on build-07" takes more than one query, the design is not finished.
Break-Glass and CA Key Management
What is this?
The operational layer: how you revoke, how you rotate CA keys, and how you log in when the CA itself is down.
Why it matters
Certificates move the single point of failure from thousands of files to one service. That is a good trade only if the service's failure modes are designed, not discovered.
How it works
ssh-keygen -k builds a compact binary Key Revocation List from certificate files, serial numbers or ranges, key IDs or raw keys. It is distributed to every host's RevokedKeys path by the same mechanism that ships the CA public keys. Because certificates are short-lived, the KRL stays tiny: it only needs to cover certificates that have not yet expired.
Issue serials monotonically per intermediate CA. If an intermediate's key is suspected compromised at 14:00, you know every serial it issued since then from the issuance log, and one KRL line revokes the range. No sshd configuration changes, no restarts: sshd re-reads the KRL per authentication.
TrustedUserCAKeys is a file of keys, so rotation is a two-step file push: add the new CA key everywhere, start issuing from it, then remove the old key. Removing the old key invalidates every certificate it signed at once, which is the big hammer when the KRL is not enough.
A separate, offline-signed, long-lived certificate for a small set of people, with source-address pinned to the out-of-band management network, stored in a sealed secret, and whose use pages the whole security team. Alternatively a per-host local emergency account whose password is in a vault and rotated after each use. Test it quarterly; a break-glass path that has never been used will not work during the incident.
Humans tolerate a CA outage badly ("nobody can log in"). Short TTLs make this worse. Run the CA as a stateless service behind a load balancer with signing keys in a KMS, and keep certificate TTL slightly longer than your CA recovery time objective.
In practiceBuilding and testing a KRL, then revoking a serial range after a suspected intermediate compromise:
$ ssh-keygen -k -f /etc/ssh/revoked.krl -s user_ca -z 1 alice-cert.pub ← -z is the KRL's own version number
$ ssh-keygen -Q -f /etc/ssh/revoked.krl alice-cert.pub
alice-cert.pub (alice laptop): REVOKED ← serial 4821 is dead everywhere this file lands
$ ssh-keygen -k -f /etc/ssh/revoked.krl -u -s user_ca -z 2 - <<< 'serial: 4800-4900' ← -u updates in place; one line kills 101 certificates
$ ssh-keygen -Q -f /etc/ssh/revoked.krl alice-cert.pub alice.pub
alice-cert.pub (alice laptop): REVOKED
alice.pub (alice laptop): ok ← the raw key is fine; only certificates are revokedWarning
RevokedKeyspointing at a missing or unreadable file makes sshd refuse all public-key authentication. Ship an empty KRL in the base image before you set the directive, and monitor the file's presence like you monitor the CA.
🎯On the job. During an incident, the revocation question is "which serials did the suspect CA or the suspect user get since time T?" That is only answerable if the issuance log records serial, key ID, principals and the identity claims. Rehearse: pick a serial, write the KRL, push it, and time how long until a login with that certificate fails on the farthest host. That number belongs in your runbook.
Interview Q&A
OpenSSH certificates are a deliberately flat format from the PROTOCOL.certkeys spec: a public key, metadata and one CA signature, with no chains, no ASN.1 and no revocation protocol beyond a local KRL file, so the server-side parser is tiny and trust is a single list of CA keys. Identity is the signed key ID, which sshd logs verbatim; authorisation is the principals list, which must contain the Unix account or a role mapped by AuthorizedPrincipalsFile; and constraints are the validity window plus critical options like source-address and force-command, which a server must understand or refuse the certificate. The catch is that the CA holds all the policy, so a sloppy CA issues perfectly valid badges to the wrong people.
Make the service the OIDC client, so the CLI starts a PKCE login but the service redeems the code and the ID token never passes through the user's process or logs; put a hash of the public key being signed into the OIDC nonce so a token can only ever produce a certificate for the key it was requested for; verify issuer, audience, signature against a cached JWKS and expiry; then store the token's jti for its lifetime and reject a second use, which closes replay. Sign with a key held in a KMS, issue monotonically increasing serials from a single counter so a race between two requests cannot produce duplicate serials, and append every issuance to an immutable log before returning the certificate.
Run a host CA: at first boot the node proves its identity with something the provisioning system can check, a TPM quote on bare metal, a cloud instance identity document, or a one-time token the VM host injected when it created the vz guest, and the host CA signs the node's host key with principals equal to every name and address clients will use, for days rather than years. Clients get a single @cert-authority line in the system-wide known hosts file, so a new host verifies on the first connection and a reimaged host with a new key is invisible to users. GitHub's 2023 RSA host key rotation is the counterexample: with TOFU, a rotation becomes a scary warning everyone clicks through, which trains people to accept the one rotation that is actually an attack.
A terminal recorder captures what the terminal displays, so stty -echo, a base64-piped payload, a non-interactive ssh host cmd, SFTP, or a script that forks and detaches all leave either nothing or an opaque blob. The kernel sees every execve, openat and connect regardless of how it was typed, and a small eBPF program on those tracepoints, filtered by the session's cgroup and carrying the audit login UID that PAM set at login, emits one record per real command and ships it off-host immediately. Correlate the cgroup with the sshd log line that holds the certificate's key ID and serial, and you can answer "what did serial 4821 run" even after the attacker cleared the shell history.
Issue it from the job's own workload identity token rather than a stored key, and encode the constraints in the certificate itself: source-address set to the runner pool's dedicated egress addresses, force-command set to the one deploy command, no forwarding extensions, a TTL of a few minutes, and a key ID carrying the run ID and commit so the server log points back to one build. The CA's policy matches on immutable claims, repository_id and job_workflow_ref on GitHub or pipeline_slug and step_key on Buildkite, never on a wildcard like repo:org/*, which would let any repository in the organisation deploy. A stolen certificate then needs the runner's IP, the dead runner's private key and a few minutes, which is close to worthless.
AuthorizedPrincipalsCommand and AuthorizedPrincipalsFile interact with PAM and local unique OS credentials to map directory groups to non-root system users?The CA writes directory groups into the certificate as role principals such as sre-oncall; on the host, AuthorizedPrincipalsFile /etc/ssh/principals/%u or an AuthorizedPrincipalsCommand running as a dedicated unprivileged user decides which roles may become which local account, so the mapping lives on the host and can be queried against inventory or change windows at login time. Once sshd accepts the login, PAM opens the session, pam_loginuid stamps the kernel audit UID that follows every child process including through sudo, and the session lands in its own systemd scope. Give each human their own Unix account rather than a shared deploy user and every file, process and sudo entry now carries the person's UID, not just the sshd log line.
Because serials are monotonic per intermediate and the issuance log says what was signed when, you write one KRL entry revoking the serial range from the suspected compromise time onward with ssh-keygen -k -u, and push that file to the RevokedKeys path every host already reads on each authentication, so no config change or restart is needed. If you cannot bound the range, remove the intermediate's public key from the TrustedUserCAKeys file, which is a data push rather than a configuration change and invalidates everything it ever signed, then issue from a fresh intermediate signed by the offline root. The reason this is survivable at all is short TTLs: with 15-minute certificates, the KRL only has to cover minutes of issuance, and the leavers you never explicitly revoked have expired anyway.
source-address protect against that a short TTL does not, and how does it interact with dedicated egress IP pools?TTL bounds when a stolen certificate works; source-address bounds where, and it is checked on the first packet of the connection against the TCP peer address, so a certificate copied off a runner or laptop fails from anywhere outside the issued range no matter how fresh it is. It only works if the legitimate path has stable addresses, which is why CI runners and bastions get dedicated NAT or egress proxy pools, one per tenant or trust tier. The server log line valid certificate but not from a permitted source address is then a high-fidelity alert, because legitimate users on the right path never produce it.
authorized_keys push. When is the certificate approach not worth it?A central push still leaves standing access between pushes, needs every host reachable to revoke, and cannot carry restrictions without per-host edits, while a certificate expires by itself, is refused offline, and carries its own source-address and force-command. The costs are a CA that must be highly available because short TTLs turn a CA outage into a login outage, an identity provider that becomes part of your SSH trust boundary, and a migration that is only done when AuthorizedKeysFile none is on every host. For a handful of pets managed by two people, a well-restricted authorized_keys with from= and command= options is fine; the certificate design pays for itself once hosts are ephemeral, people change often, or an auditor asks who could log in where yesterday at 14:00.