SSH Auth
Log into a CLI with the SSH key you already have. No browser round trip, no device code to type in -- works over an agent-forwarded SSH session into a remote box, and in headless CI where there is no browser at all.
What it is, and why
Passkeys cover the browser. SSH auth is the terminal equivalent: your CLI tool proves possession of an SSH key by signing a server-issued challenge, and exchanges that signature for an OAuth access token. The private key never leaves ssh-agent -- the same agent that already handles your Git pushes and remote logins, including hardware keys (sk-*), 1Password's SSH agent, and agent forwarding into a devbox over SSH.
Three things fall out of that design:
- No browser round trip. A CLI tool can complete a login entirely from stdin/stdout.
- Works over forwarded agents. SSH into a remote devbox with
ssh -A, and your local key signs the challenge from there too. - Works in headless CI. Load a deploy key into an ephemeral agent and authenticate with no interactive prompt.
All of this runs through the same HTTP stack as every other grant -- rate limiting, tenancy, and audit logging are not reinvented for SSH. The signature verification is modeled as a custom OAuth grant, not a side channel.
The challenge → sign → token flow
Three steps, all against your tenant's normal OAuth endpoints:
POST /oauth/ssh/challenge-- the client sends itsclient_id(and optionalscope); the server returns a single-usechallenge_idand a randomnoncegood for 60 seconds. This endpoint never discloses enrolled-key information -- it's not an oracle for anything.- Sign. The client builds the canonical payload below and signs it via
ssh-agent, producing anSSHSIG (thessh-keygen -Y signformat) in the[email protected]namespace. The namespace gives domain separation -- a signature made for us can never be replayed as an SSH login or a Git commit signature, and vice versa. POST /oauth/tokenwithgrant_type=urn:passkeybase:params:oauth:grant-type:ssh-- the server reconstructs the same canonical payload server-side (it never trusts a client-supplied payload), verifies the signature, resolves the signing key to an enrolled user, and mints an access token.
The canonical payload is fixed, newline-terminated text -- nothing about it is negotiable:
passkeybase-ssh-v1
nonce=<nonce>
aud=https://acme.auth.ing
client_id=acme-cli
scope=deployAlgorithm policy: ssh-ed25519,[email protected],ecdsa-sha2-*, andrsa-sha2-256/512 are accepted. Legacy ssh-rsa (SHA-1) andssh-dss are rejected.
Using @passkeybase/cli
@passkeybase/cli is a small TypeScript library your own CLI tool embeds -- it is not a standalone command. It drives the whole flow above: it talks to SSH_AUTH_SOCKover the raw agent wire protocol first, falls back to shelling out tossh-keygen -Y sign -U for identities the direct path can't drive (hardware keys the agent handles specially), and falls back again to a default on-disk key (~/.ssh/id_ed25519,id_ecdsa,id_rsa, in that order) if no agent is reachable.
npm install @passkeybase/cliCall login() with your tenant's origin and the client ID from the dashboard:
import { login } from '@passkeybase/cli';
const tokens = await login({
baseUrl: 'https://acme.auth.ing',
clientId: 'acme-cli',
scope: 'deploy',
});
console.log(tokens.access_token);Pass an explicit sshKeyPath to skip the agent and sign directly with a given private key file, or akeySelector to restrict which agent identities are tried when the agent has several keys loaded.
Zero-install: stock OpenSSH + curl
There's no proprietary magic in the protocol -- the whole flow can be driven with tools already on your machine. This is the same flow the CLI library automates, spelled out withcurl andssh-keygen for anyone who wants to see exactly what's happening on the wire, or for a shell script that shouldn't take a Node dependency:
# 1. get a challenge
resp=$(curl -s https://acme.auth.ing/oauth/ssh/challenge \
-d client_id=acme-cli)
# 2. build the canonical payload and sign it via the agent
sig=$(printf '%s' "$payload" | \
ssh-keygen -Y sign -n [email protected] -U -f ~/.ssh/id_ed25519.pub)
# 3. redeem it
curl -s https://acme.auth.ing/oauth/token \
-d grant_type=urn:passkeybase:params:oauth:grant-type:ssh \
-d client_id=acme-cli -d challenge_id=... --data-urlencode signature="$sig"Requires OpenSSH ≥ 8.0 (2019). -U tellsssh-keygen to sign via the running agent, selecting the key by its public half. The server accepts the raw binary SSHSIG blob (base64url) or the PEM-armored -----BEGIN SSH SIGNATURE----- text exactly as ssh-keygen -Y sign emits it, so there's no dearmoring step required in shell.
Enrolling keys
A key has to be enrolled before it can authenticate. Enrolled keys show up in the same credential list as passkeys.
From the dashboard
The default path: sign in, add a public key from your account page (or, for Express-native tenants, via the Management API), and it's immediately usable.
GitHub import
During enrollment, type a GitHub handle instead of pasting a key. PasskeyBase fetcheshttps://github.com/<user>.keysand completes enrollment once you prove possession of one of the listed keys via a normal challenge. GitHub enforces key uniqueness across its own accounts, so proving possession against a handle's key list binds the key to that identity. After import there's no ongoing dependency on GitHub -- the key is a direct enrollment from that point on.
Troubleshooting
@passkeybase/cli throws typed errors so your CLI can print something actionable instead of a stack trace:
import {
login,
NoAgentError,
NoUsableKeyError,
InvalidGrantError,
} from '@passkeybase/cli';
try {
const tokens = await login({
baseUrl: 'https://acme.auth.ing',
clientId: 'acme-cli',
});
} catch (err) {
if (err instanceof NoAgentError) {
console.error('No ssh-agent reachable and no ~/.ssh key found. Run "eval $(ssh-agent)" and "ssh-add".');
} else if (err instanceof NoUsableKeyError) {
console.error('ssh-agent has no identity enrolled with PasskeyBase.');
} else if (err instanceof InvalidGrantError) {
console.error('Challenge expired, already used, or key not enrolled for this client.');
} else {
throw err;
}
}NoAgentError -- no agent
SSH_AUTH_SOCK is unset or unreachable, and no default key was found under ~/.ssh. Start an agent (eval $(ssh-agent)) and load a key (ssh-add), or passsshKeyPath explicitly.
NoUsableKeyError -- no usable key
The agent is reachable, but none of its loaded identities (after anykeySelector filtering) could produce a signature -- typically an empty agent, or akeySelector that matched nothing. Check ssh-add -l.
InvalidGrantError -- invalid grant
The server rejected the redemption: the challenge expired (60s TTL), was already used, or the signing key isn't enrolled for this client. This error is deliberately uniform -- it never distinguishes "wrong key" from "unknown key" from "expired challenge", so the token endpoint can't be used to enumerate enrolled keys.
Next steps
- OAuth Flow -- how the SSH grant fits alongside the browser flow
- Enrollment -- add credentials to existing auth systems
- API Reference -- Management API endpoints