AuthBox documentation — User guide

Documentation embedded in this build.

Logging in to a machine

You have to get a shell on a server. Maybe several hundred servers, maybe a node of a Kubernetes cluster. The way that has always worked is that somebody copies your public key into a file on each machine, and from then on you are whoever that file says you are — until somebody remembers to take it out again.

AuthBox issues you an OpenSSH certificate instead. You keep one key, nobody copies it anywhere, and what you present is a certificate that expires in hours and is renewed while you work. The machines you log in to hold two small files and never talk to AuthBox at all.

This page is what that looks like from your side. The one thing worth reading twice is the next section, because it is the part that is genuinely different and the part that causes every confused first login.

The account you log in as is earned, not chosen

An OpenSSH certificate carries a list of principals, and a principal is a login name: deploy, web, root. Whichever names are on your certificate are the accounts you may log in as, on every machine that trusts the authority. Any name that is not on it, you may not.

Everywhere else in the world that list is written into a template by whoever runs the CA. Here it is not written at all. It is computed, at the moment your certificate is issued, from the same things every other decision about you is made from: the missions you hold, the groups you are in, the clearance on your record, and — where the machines you are reaching require it — whether your training is current. You do not ask for a principal. There is no field anywhere in the request for one, which is deliberate: the whole point is that "can this person ever become root on the web fleet" is answered by the policy and not by reading a template and hoping.

Three consequences you will actually feel.

You may get more than one. If your standing earns you both deploy and root on a fleet, one certificate carries both and you choose per login which you use. If it earns you neither, you get no certificate at all and a refusal that names the clause you did not satisfy — not an empty certificate, because an OpenSSH certificate with an empty principal list means valid for every login name, which is the exact inverse of what anybody wants.

Your certificate is per fleet, not per machine. The invitation you claim names a profile, the profile names a fleet, and the fleet is the set of machines the principals were computed for. Two fleets means two certificates.

A change in your standing takes effect at your next renewal, not instantly. Lose the mission and your current certificate keeps working until it expires — hours, not months, which is why nobody has to revoke anything. Gain the mission and your next renewal carries the new principal, because the list is recomputed every single time.

Your key has to be ECDSA, and that is a real cost

The SSH world's default key is Ed25519. This deployment does not certify one, and it will tell you so by name rather than telling you your key is malformed:

$ authboxclient enroll ... -ssh-pubkey ~/.ssh/id_ed25519.pub
authboxclient: the ssh-ed25519 key presented is not one this deployment certifies

The refusal is a policy decision and not a limitation of the cryptography — under this build's FIPS posture Ed25519 is available and declined. The question this page used to point at, ARCHITECTURE.md §17 Q10, has since been closed, and it closed about X.509: accepting an Ed25519 client certificate is a tier-1 setting in both postures, and issuing one is the open build's. Neither moves SSH. The list this refusal comes from is internal/sshcert's own, it is not build-tagged, and so an Ed25519 SSH certificate is refused in both builds alike — one enum entry away on the day somebody asks for it, which nobody has. Until then this is the largest single piece of friction in the feature and it is worth stating plainly: if your only key is ~/.ssh/id_ed25519, you generate a second one.

ssh-keygen -t ecdsa -b 256 -f ~/.ssh/id_ecdsa

-b 384 works too. Nothing else does: not RSA, not P-521, not a security key. Keep the Ed25519 key for everywhere else that wants it — having two keys costs you nothing, because what you present here is the certificate and not the key.

Enrolling

You need an invitation token, the same kind enrolment describes, from whoever administers the deployment. It is bound to you by name before you ever see it, and that binding is where the certificate's identity comes from — which is why there is no -subject to type for this path and why an invitation naming nobody is refused.

authboxclient enroll -server https://authbox.example:8443 -ca ca.pem \
  -invitation TOKEN -ssh-pubkey ~/.ssh/id_ecdsa.pub

That writes ~/.ssh/id_ecdsa-cert.pub beside your key — the name OpenSSH looks for on its own, so in the simplest case there is nothing further to configure.

From a file, with -invitation-file, when the token arrives as one rather than as something to paste. That is the ordinary case for anything that is not a person: a mounted Kubernetes Secret, a deployment's own secret channel, the same shape every AuthBox service reads its own join token from (*.credential.invitation_file). It is also a machine's only option where there is no shell to read the file with — the node-access overlay's host enrolment runs in a FROM scratch image and does exactly this.

authboxclient enroll -server https://authbox.example:8443 -ca ca.pem \
  -invitation-file /etc/secrets/invitation -ssh-pubkey /etc/ssh/ssh_host_ecdsa_key.pub

Trailing whitespace is trimmed, because a file written by a shell redirect has a newline on the end and a token with a newline on the end is a token the server does not hold. Passing both flags is refused rather than resolved in some order.

By hand, because the intake is a public HTTP surface and always has been. The body is one authorized_keys line and the token is a header:

curl -sS --cacert ca.pem \
  -H "X-AuthBox-Invitation: TOKEN" \
  --data-binary @$HOME/.ssh/id_ecdsa.pub \
  https://authbox.example:8443/enroll > ~/.ssh/id_ecdsa-cert.pub

There is no queue for this one. An SSH enrolment needs an invitation that issues immediately, and one that would have been held for an administrator's approval is refused up front with that reason — because what an administrator approves in the queue is a subject and a key proposed together, and a bare public key is not that.

What you actually got

Read it back with OpenSSH's own tool, which is the only opinion that matters:

$ ssh-keygen -L -f ~/.ssh/id_ecdsa-cert.pub
        Type: ecdsa-sha2-nistp256-cert-v01@openssh.com user certificate
        Public key: ECDSA-CERT SHA256:9Xk…
        Signing CA: ECDSA SHA256:pQ2… (using ecdsa-sha2-nistp256)
        Key ID: "cn=alice,o=acme,c=us"
        Serial: 7325216886318951063
        Valid: from 2026-09-14T13:46:00 to 2026-09-15T01:47:11
        Principals:
                deploy
        Critical Options:
                source-address 10.20.0.0/16
        Extensions:
                permit-pty
                permit-user-rc

Key ID is your canonical subject name, and it is what the machine's log line will say about the login — so an investigator reading sshd logs sees who you are, not which key you used. Principals is the list the policy computed. Valid is twelve hours by default. Critical Options and Extensions come from the fleet and the profile: source-address restricts where you may present the certificate from, permit-pty is what makes an interactive shell possible at all. You did not ask for any of them and cannot.

Using it

If the certificate sits beside the key with the -cert.pub name, ssh finds it:

ssh deploy@web-3.prod.acme

If it does not, or if you keep certificates somewhere else, say so once in ~/.ssh/config rather than passing flags forever:

Host *.prod.acme
    User deploy
    IdentityFile ~/.ssh/id_ecdsa
    CertificateFile ~/.ssh/id_ecdsa-cert.pub
    IdentitiesOnly yes

IdentitiesOnly yes is worth having: without it ssh will happily offer every key your agent holds before the certificate, and a machine that rejects three keys before accepting one looks, in the logs, exactly like an attack.

To log in as a different principal you already hold, change the user and nothing else — ssh root@web-3.prod.acme presents the same certificate, and the machine decides from the list on it.

Renewing, and what expiry feels like

Twelve hours is a working day. Renew once a shift, or let something renew for you:

authboxclient renew -server https://authbox.example:8443 -ca ca.pem YOUR-CREDENTIAL-ID

This is renewal by possession — you prove you still hold the credential this deployment already gave you, and the principals are recomputed from scratch, which is the mechanism by which a membership change takes effect without anybody revoking anything. authboxclient credentials list reminds you of the ID.

When it lapses, ssh does not say your certificate expired. It silently stops offering it and falls through to asking for a password you do not have, so the symptom is Permission denied (publickey) and the diagnosis is one command:

ssh-keygen -L -f ~/.ssh/id_ecdsa-cert.pub | grep Valid

If the window has closed, renew. That is the whole procedure, and it is the same one for a certificate that expired an hour ago and one that expired in March.

When a login is refused

In the order worth checking, because each of these looks identical from the client:

The window closed. Above. Most refusals are this.

You do not hold that principal. ssh-keygen -L lists what you hold. If the account you are trying to become is not in it, no machine will let you have it, and the remedy is standing rather than configuration — ask for the mission, or the training, through asking for access. Nothing you can do to your SSH configuration changes this.

You are presenting from an address the certificate forbids. If source-address is on the certificate, sshd refuses a login from anywhere else and says almost nothing useful about why. Check the option against where you actually are — a VPN change or a new office is the usual cause.

The machine narrows it further. A deployment may also keep a per-machine list of which principals map to which local accounts. That list can only ever refuse something your certificate already permits, never grant something it does not — so if ssh-keygen -L says you hold the principal and one particular machine still says no, that machine is the place to ask about, not your certificate.

Your certificate was revoked. Rare, because short validity usually makes revocation unnecessary. See below.

Trusting the machines back

The other half of SSH is the half everybody gives up on: the first-connection prompt asking whether some fingerprint you have never seen is really the server you meant. Where the machines are certified too, that prompt goes away — and it goes away for the right reason, not by being switched off.

Fetch the authority's public half, which is served to anybody and needs no credential:

curl -sS --cacert ca.pem https://authbox.example:8443/enroll/ssh-ca

The body is one line, in the same format as an authorized_keys entry. To turn it into a statement about which machines it may certify, put @cert-authority and a host pattern in front of it and drop the trailing comment:

$ curl -sS --cacert ca.pem https://authbox.example:8443/enroll/ssh-ca \
    | awk '{print "@cert-authority *.prod.acme", $1, $2}' >> ~/.ssh/known_hosts

From then on ssh verifies each machine's own certificate against that one line, for every machine in the zone, for as long as the authority is the authority. No prompt, no accumulating list of fingerprints, and nothing to re-accept when a machine is rebuilt.

Two things to be careful about. The pattern is yours to choose and it should be as narrow as the truth allows — @cert-authority * tells your client to accept that authority's word about every hostname in the world. And if the deployment tells you the authority key changed, that line is what has to change; a machine presenting a certificate from a key you do not list is a machine you will be prompted about, which is the failure working correctly.

If your laptop is lost

This is the case the design is actually for, and the answer is that it is close to a non-event. What was on the laptop stops working this afternoon whether or not anybody reported it, because the certificate expires in hours and renewal requires standing the deployment can withdraw in one edit. Tell somebody anyway — that is what makes the renewal stop — but there is no race against a key that works for a year.

If the deployment does want the certificate refused before it lapses on its own, an administrator records that intent and the authority publishes a revocation list which is then copied to the machines. It is worth knowing the honest shape of this: the list is a file on each machine, sshd reads whatever version of it happens to be there, and there is no mechanism anywhere by which a machine refuses to let you in because its copy is old. That is why short validity is the real protection and the list is only the tail.

Nothing of ours runs on your servers

Worth stating because it is the question everyone asks and the answer is unusually short. There is no agent, no PAM module, no patched sshd, and no hook that calls back to AuthBox when you log in. A machine decides your login from two files on its own disk with no network involved, which means logins keep working when AuthBox is down, unreachable, or on the other side of an airgap. That is true whether you reach the machine directly or through a front door (below): what decides the login on the host never changes.

The consequence is worth holding onto as well: the host does not phone home when you log in. AuthBox knows it issued you a certificate, and it can say exactly which principals it computed and which clauses produced them. What happened afterwards, on a machine deciding from two files, is recorded on that machine.

Optionally, through the front door

There is one thing this can now be, if you want it: carried through AuthBox's front door (docs/plans/135). An operator can put a kind: tcp route on the same door that fronts your HTTP services, and your SSH session travels inside that door's mutual TLS — routed by the name you put in the SNI, authorized at the handshake against the same require: every other route uses, receipted, and recorded — and then spliced to the host's sshd, which the door reads not one byte of. You opt into it with two lines in ~/.ssh/config:

Host *.acme.example
  ProxyCommand authboxclient ssh-proxy -door door.acme.example:8443 -sni ssh.%h

Be honest about what that does and does not change. It changes the path the bytes take: they now reach the host through a door that adds a decision, a receipt and a record — a second gate, in front of the host's own. It does not change how the host decides your login: your session is still end to end under SSH's own keys, the door holds no SSH key and cannot read the session, and the host still decides the account from its own two files with no network. So there are now two engines, each deciding what is its to decide — the door whether you may reach the host at all, the host which account your certificate's principals allow — and neither can grant what the other refuses. The door also cannot see a login; what it records is the connection it carried, not the session inside it.

Two things this still is not, even through the door: a session recording (nothing records what you type), and a token for some other system (the certificate is one credential for one protocol). In this shape the door is a path, not a bastion — it terminates TLS and never SSH.

Where to try it. The all-in-one composition (make all-in-one) runs a real target for exactly this: a machine called bastion.prod.authbox.example, with a real sshd on it reading the two files, reached through the door at ssh.<domain> with the ProxyCommand above (the name you give -sni is ssh.<domain>, and the host you name is the target's). The operator's side of that composition — how the target is built and what it trusts — is the SSH CA runbook, section 10.

One thing you will hit if you go looking for a shortcut. That fleet's certificates carry a source-address critical option of 192.0.2.0/24 — a range reserved for documentation (TEST-NET-1) — by deliberate design, so that the certificate itself says where it may be used from, and sshd enforces it before authentication is even attempted. A login from your own machine's address is therefore refused, not because anything is broken but because that is the clause working. The composition places the door inside that range, which is why the fronted path is the one to try, and why the target publishes no host port of its own: there is no side entrance to reach it by.

Or as a jump host

If your SSH client configuration is locked, your tooling has a -J flag and nothing else, or you have simply learned ssh -J bastion host and would rather learn nothing of ours, the same door can be the jump host (docs/plans/136). An operator turns it on with proxy.ssh.listen on the door and proxy.routes[].jump on the route, and from your side it is one flag:

ssh -J door.acme.example:2222 deploy@web-3.prod.acme

You present the same certificate at both hops, so put it in your config once for both:

Host door.acme.example web-*.prod.acme
    IdentityFile ~/.ssh/id_ecdsa
    CertificateFile ~/.ssh/id_ecdsa-cert.pub
    IdentitiesOnly yes

The door is certified too. Its host certificate is issued by the same authority the machines' are, so the one @cert-authority line you already wrote covers it, and there is no prompt at either hop. ProxyJump in ~/.ssh/config is the same thing spelled once, and so is the older ProxyCommand ssh -W %h:%p door, because both open the one channel the door serves — a direct-tcpip to a host the door's route table declares.

What the door does with that hop is decide whether you may reach the host at all, on who you are — the subject your certificate's key id names — against the route's own requirement, exactly as it decides every other request; it never reads the principals on your certificate, because which account you become is the host's question, answered as it always was. Then it copies bytes. Your session to the host is your own SSH transport under keys the door does not hold; the door can neither read it nor alter it, and it records that it carried a channel for you, never what was in it.

What a refusal looks like is worth knowing, because it arrives in OpenSSH's own words:

$ ssh -J door.acme.example:2222 deploy@web-3.prod.acme
channel 0: open failed: administratively prohibited: route web-3.prod.acme refused
this subject (correlation 5b0c1e6a92ad4f3f8c7d0e1a2b3c4d5e)

The correlation id is the record at the door; give it to whoever administers the deployment and they can read exactly which clause refused you — the door mints a receipt for every channel it decides, permitted or refused, and the refusal never names the group on your terminal. A host the door does not declare says so instead: no route declares lab-9.prod.acme:22 as reachable by a jump.

Three things the door refuses by name and will keep refusing. A shell on the door (ssh door, or a tool that jumps with ProxyCommand ssh door nc %h %p, which asks for a shell to run nc in) is answered session is refused at this door; only direct-tcpip to a declared route — there is no shell on this door and no flag that adds one. Port forwarding through it (-R, -L to anything but a declared host) and agent forwarding are declined the same way. And a plain key is not a credential there: a door that accepted one would be an authorized_keys file on a machine that is supposed to hold none, so if you see Permission denied (publickey) at the door, the first thing to check is that your certificate is beside your key and still inside its window (ssh-keygen -L) — the door also refuses everyone, on purpose, while its copy of the revocation list is older than the deployment allows, which is a fact about the door's courier and not about you.