100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

Tutorials

Tutorial: SSH key request signing

Authenticate to the HTTP transport with the SSH keys and certificate authorities you already run, using the ssh-sign helper, an authorized_keys file with scope options, and an SSH CA that issues scoped user certificates.

Most organisations already have a trustworthy answer to "which humans and which machines are allowed on this host": an authorized_keys file, or an SSH certificate authority that signs short-lived user certificates. Secronyx can reuse that answer directly. A client signs a canonical description of its HTTP request with an SSH private key and sends the signature in the Authorization header; the server verifies it against the same key material sshd uses, and reads the caller's name and scopes from the key file.

This walkthrough builds that from nothing: a key, a key file with scopes, the ssh-sign helper, a call with curl, every rejection path, then the certificate flow with an SSH CA. Everything below was run against the current build and the outputs are what it printed. Reference material is on Authentication, Scopes and authorization and Command line.

What is actually signed

The signed string is defined in internal/sshauth/canonical.go and has seven newline-separated lines: the version constant secronyx-http-sig-v1, the upper-cased method, the request URI (path and query exactly as sent), the lower-cased Host header, the Unix timestamp, the nonce, and the hex SHA-256 of the body. Because the version constant is the first line, changing the canonical form makes every old signature unverifiable rather than ambiguous.

Consequences worth internalising before you start: the signature is bound to one method, one path including its query string, one host and one body. Change any of them and verification fails. Signatures are good for five minutes either side of the server clock, and each nonce is accepted once, so a captured header cannot be replayed. RSA keys always sign with rsa-sha2-512; SHA-1 ssh-rsa signatures and DSA keys are refused outright, as are RSA keys under 2048 bits.

1. Make a key and an authorized_keys file

Any key type ssh-keygen produces will do except DSA, except that a security-key (sk-*) type has to be signed through --agent: the helper cannot read an sk-* private key file. Ed25519 keys are the smallest.

mkdir -p ~/ssh-lab && cd ~/ssh-lab && umask 077
ssh-keygen -t ed25519 -f alice -N '' -C 'alice@ops'
ssh-keygen -lf alice.pub
256 SHA256:csh0Q9q2MVVA6LiOXR45VYJs69t7ht2rWADV9jfn8vs alice@ops (ED25519)

The key file uses ordinary authorized_keys syntax: options, then the public key, then a comment. The comment becomes the audit identity.

printf 'scopes="core,logs" ' > authorized_keys
cat alice.pub >> authorized_keys
chmod 600 authorized_keys
cat authorized_keys
scopes="core,logs" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILGW1X2uWmAkN0yGS2JtMw0tOSDF6FeI3Qb04eX+P94x alice@ops

Exactly four options are understood, and anything else is a fatal parse error so that a typo cannot quietly widen access:

Option Meaning
scopes="core,logs" The tool scopes this key grants. Omitting it grants none.
expires="2026-12-31" Valid through the end of that UTC day, or an RFC 3339 instant.
from="10.0.0.0/8,192.0.2.7" Client addresses allowed to use this key. Bare IPs are accepted.
principals="alice,oncall" CA lines only: which certificate principals that CA may assert.

Omitting scopes is not a shortcut for "everything". A key with no scopes= option authenticates successfully and then sees nothing:

# authorized_keys containing just the public key, no options
curl ... -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
# 0
# {"jsonrpc":"2.0","id":1,"error":{"code":-32003,"message":"Forbidden","data":"tool \"get_uptime\" requires scope \"core\""}}

2. Start the server

secronyx --transport http --listen 127.0.0.1:8099 \
  --ssh-authorized-keys ./authorized_keys \
  --audit-output ./audit.jsonl
Audit logging enabled: provider=default output=./audit.jsonl
Scope policy: 9 tools not registered (sensitive=false, scopes="")
MCP HTTP Server starting on 127.0.0.1:8099
  Server URL: http://127.0.0.1:8099
  Auth:       ssh-signature

The key file is parsed at startup and a bad file stops the server rather than admitting nobody silently:

Error configuring SSH signature auth: sshauth: authorized keys: ./bad_keys: line 1: unsupported option "scope"
Error configuring SSH signature auth: sshauth: authorized keys: ./bad2: line 1: expires "soon": use YYYY-MM-DD or RFC 3339
Error configuring SSH signature auth: sshauth: authorized keys: stat ./nope: no such file or directory

Later edits are picked up without a restart: the file's mtime is polled at most every two seconds, and a reload that fails to parse keeps the last good key set.

The lab binds to loopback without TLS, which is the only shape the exposure rules allow without a certificate. In production use --tls-cert/--tls-key and --server-url; see Remote access over HTTP.

3. Sign a request and send it

secronyx ssh-sign builds the header. It never contacts the server; it only needs the URL, the method, the body and a key.

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' > list.json

secronyx ssh-sign --key alice --url http://127.0.0.1:8099/ --body list.json
SSH-Sig keyid="SHA256:csh0Q9q2MVVA6LiOXR45VYJs69t7ht2rWADV9jfn8vs", ts="1789659126", nonce="_FWOnNgnqTT4mAabJo3zsQ", sig="AAAAC3NzaC1lZDI1NTE5AAAAQM5Eb0+tlrBTIELkFaK9PGEr1e1Dh7+iIxj7jcsRbchKmLUTcTJvWon5M2s+59uYJ9OKslNhE83XK1jC+NETMgg="

Four quoted fields: the signing key's SHA-256 fingerprint, the Unix timestamp, a 16-byte base64url nonce, and the base64 SSH wire-format signature. A fifth field, cert="...", appears in the certificate flow below. The whole header is 235 characters for an Ed25519 key.

--curl prints the complete command instead, which is the form to use in scripts:

secronyx ssh-sign --key alice --url http://127.0.0.1:8099/ --body list.json --curl
curl -sS -X POST 'http://127.0.0.1:8099/' -H 'Authorization: SSH-Sig keyid="SHA256:csh0Q9q2MVVA6LiOXR45VYJs69t7ht2rWADV9jfn8vs", ts="1789659126", nonce="etlll8tSqrbLQ5bM-9jE9w", sig="AAAAC3NzaC1lZDI1NTE5AAAAQEN/wwvSHtemj95QCX7QPbTpWPBTgyyOg+wgfgxQJgfK83mAAUsAkZGRKKcedv0X2PG4fg0u2RHVGIvJOyL8XAc="' -H 'Content-Type: application/json' --data-binary '@list.json'
eval "$(secronyx ssh-sign --key alice --url http://127.0.0.1:8099/ --body list.json --curl)" \
  | jq '.result.tools | length'
# 13

Thirteen tools: the eight in core plus the five in logs, which is what scopes="core,logs" grants. Doing it by hand is the same two values:

H=$(secronyx ssh-sign --key alice --url http://127.0.0.1:8099/ --body list.json)
curl -s -X POST http://127.0.0.1:8099/ -H "Authorization: $H" \
  -H 'Content-Type: application/json' --data-binary @list.json | jq '.result.tools | length'
# 13

Sign a fresh header for every request. Re-sending the header above a second time is exactly what replay protection exists to stop:

{"error":"unauthorized","error_description":"ssh-signature: replayed nonce"}

A GET is signed the same way with --method, and no body:

H=$(secronyx ssh-sign --key alice --method GET --url http://127.0.0.1:8099/metrics)
curl -s -H "Authorization: $H" http://127.0.0.1:8099/metrics | head

Other ssh-sign inputs: --body - reads the body from stdin (note that the --curl rendering then emits --data-binary '@-', which would read stdin a second time, so write the body to a file when you want the curl form); --passphrase-env VAR names an environment variable holding the passphrase for an encrypted key; --agent and --fingerprint use ssh-agent instead of a file.

ssh-add alice
# with more than one key loaded, the helper will not guess:
secronyx ssh-sign --agent --url http://127.0.0.1:8099/ --body list.json
# Error: 3 keys available; choose one with --fingerprint
secronyx ssh-sign --agent \
  --fingerprint SHA256:csh0Q9q2MVVA6LiOXR45VYJs69t7ht2rWADV9jfn8vs \
  --url http://127.0.0.1:8099/ --body list.json

With exactly one key loaded the fingerprint may be omitted, and a fingerprint that no loaded key matches is refused with no key with fingerprint <fp>. --agent is also how a FIDO security key (sk-ssh-ed25519) signs: such keys are accepted by the verifier, but the helper cannot load one from a file.

The helper refuses weak or unreadable keys before it signs anything:

Error: private key weak: RSA key is 1024 bits; at least 2048 required
Error: private key mallory is readable by others (mode 0644); run chmod 600
Error: private key rsakey is encrypted; a passphrase is required
Error: use either --key or --agent, not both

4. Every way a signature is rejected

Each is an HTTP 401 with a JSON body whose error_description is prefixed with the authenticator name, ssh-signature, plus the shared challenge header.

HTTP/1.1 401 Unauthorized
Content-Type: application/json
Www-Authenticate: Bearer realm="secronyx", resource_metadata="http://127.0.0.1:8099/.well-known/oauth-protected-resource"

{"error":"unauthorized","error_description":"no acceptable credentials presented"}
What you did error_description
Sent the body that was not signed ssh-signature: signature verification failed
Signed for localhost, sent to 127.0.0.1 ssh-signature: signature verification failed
Signed /?x=1, sent to / ssh-signature: signature verification failed
Re-sent a header ssh-signature: replayed nonce
Used a key not in the file ssh-signature: unknown key
Edited ts to an hour ago ssh-signature: signature timestamp outside ±5m0s window
Called from outside from= ssh-signature: client address not permitted by from= restriction
Used a key whose expires= has passed ssh-signature: key entry has expired
Sent keyid=abc unquoted ssh-signature: credential field "keyid" must be quoted
Sent a non-fingerprint keyid ssh-signature: keyid must be a SHA256 fingerprint

The skew and nonce windows are fixed in the code at five and ten minutes; they are not flags. The timestamp is checked before the signature, so a stale header is rejected without spending a verification. The nonce, conversely, is only recorded after the signature verifies, so a forger cannot burn nonces on a real key's behalf. Nonces are held in a bounded cache of 100,000 entries, oldest evicted first.

The address used for from= is the one the server resolved for the audit log: the TCP peer, or the first X-Forwarded-For entry only when --trust-proxy-headers is set. Without that flag a caller cannot forge a permitted network with a header.

A scope failure is not an authentication failure. It happens after the identity is established, at the JSON-RPC layer:

{"jsonrpc":"2.0","id":3,"error":{"code":-32003,"message":"Forbidden","data":"tool \"get_listening_ports\" requires scope \"hooks\""}}

5. Certificates instead of a key list

An authorized_keys file has to be distributed to every host and pruned when someone leaves. An SSH CA removes both problems: the server trusts one CA public key, and access expires because the certificate does.

ssh-keygen -t ed25519 -f ssh_ca -N '' -C 'Ops SSH CA'
ssh-keygen -t ed25519 -f carol -N '' -C 'carol@ops'

ssh-keygen -s ssh_ca -I carol@ops -n carol -V +1h \
  -O extension:secronyx-scopes=core,logs carol.pub
# Signed user key carol-cert.pub: id "carol@ops" serial 0 for carol valid from ... to ...

The trusted-CA file is the same authorized_keys syntax, holding CA public keys:

printf 'principals="carol",scopes="core" ' > ca_keys
cat ssh_ca.pub >> ca_keys
chmod 600 ca_keys

secronyx --transport http --listen 127.0.0.1:8099 \
  --ssh-authorized-keys ./authorized_keys --ssh-ca-keys ./ca_keys

Both flags may be used together, as here, or either alone. With only --ssh-ca-keys, a bare key is told bare keys are not accepted; present a certificate; with only --ssh-authorized-keys, a certificate is told certificates are not accepted.

Signing with a certificate adds --cert:

H=$(secronyx ssh-sign --key carol --cert carol-cert.pub \
      --url http://127.0.0.1:8099/ --body list.json)
curl -s -X POST http://127.0.0.1:8099/ -H "Authorization: $H" \
  -H 'Content-Type: application/json' --data-binary @list.json | jq '.result.tools | length'
# 8

The header now carries a fifth field, cert="<base64 certificate>". The signature is still made by the private key; the certificate is what ties that key to a CA and a principal.

Scopes come from one of two places, in this order. If the CA line carries scopes=, that wins for every certificate from that CA — above, scopes="core" on the CA line is why Carol saw 8 tools and not the 13 her certificate's extension asked for. If the CA line has no scopes=, the secronyx-scopes certificate extension is used instead:

printf 'principals="carol" ' > ca_keys2 && cat ssh_ca.pub >> ca_keys2
# same certificate, CA line without scopes=
# → 13 tools: core + logs, exactly the extension's value

Put scopes= on the CA line when the server, not the CA, should decide what a certificate is worth. Leave it off and use the extension when the CA is the authority on entitlement — which is the usual choice when the CA is your SSO-backed certificate issuer.

The identity in the audit log is the certificate principal (carol), not the key comment. Which principal is chosen: the first one the CA line's principals= list permits, or the certificate's first principal when the CA line does not restrict them.

Certificate rejections:

{"error":"unauthorized","error_description":"ssh-signature: certificate principals not permitted by CA entry"}
{"error":"unauthorized","error_description":"ssh-signature: certificate rejected: ssh: cert has expired"}
{"error":"unauthorized","error_description":"ssh-signature: certificate not issued by a trusted CA"}

Host certificates are refused (certificate is not a user certificate), a credential whose keyid field does not match the certificate's own public key is refused (keyid does not match certificate key), and a certificate's source-address critical option is enforced on top of any from= on the CA line — so ssh-keygen -O source-address=10.0.0.0/8 pins a certificate to a network at issue time.

6. Confirm the trail

Every request is authenticated independently, so an auth/token_validation event precedes each tools/call.

jq -c '{seq,action,identity,client_ip,result,m:.metadata}' audit.jsonl
{"seq":1,"action":"auth/token_validation","identity":"carol","client_ip":"127.0.0.1","result":"success","m":{"client_id":"ssh:SHA256:DYnL7mPN9YrDFHjTivuXNYz9tCFVBX+2r5mnoVxr5l0","method":"ssh-signature","scopes":["core"]}}
{"seq":2,"action":"auth/token_validation","identity":"alice@ops","client_ip":"127.0.0.1","result":"success","m":{"client_id":"ssh:SHA256:csh0Q9q2MVVA6LiOXR45VYJs69t7ht2rWADV9jfn8vs","method":"ssh-signature","scopes":["core","logs"]}}

identity is the key comment for a bare key and the certificate principal for a certificate; client_id is always ssh: followed by the signing key's fingerprint, which is the value to pivot on when the same person has several keys. A key with no comment is named by its fingerprint instead. Tutorial: verify the audit chain shows how to prove this file has not been edited.

Operating guidance

  • Prefer certificates for humans and authorized_keys for a small number of long-lived machine identities. A certificate that expires in an hour is a better control than any revocation process.
  • Scope every line. An entry without scopes= is a working credential that can do nothing, which is a safe default but a confusing one to debug; write the scopes you mean.
  • Set expires= on bare keys even when you expect to remove them by hand, and from= on machine keys whose network is known.
  • Signing is per-request, so wrap ssh-sign in whatever runs your requests rather than caching a header. The helper is a single binary invocation and needs no network access.
  • Behind a reverse proxy, the proxy must not alter the method, path, query or body, and must pass the Host the client signed — or terminate at the server. Set --trust-proxy-headers so from= and the audit log see the real client.
  • The credential chain runs API keys first, SSH signatures second and mTLS last, so a host can offer all three at once: see Tutorial: per-operator API keys and Tutorial: mutual TLS end to end. Back to the documentation index.

Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.