Security
Authentication
The HTTP credential chain and its order, each method from static tokens to OIDC, introspection, API keys, mutual TLS and SSH request signing, how each carries scopes, and the rate-limit, lockout and replay controls around them.
Secronyx authenticates callers only on the HTTP transport. On stdio there is no credential exchange: the MCP client starts the process, the process runs with that user's privileges, and the operating system is the access control. Everything on this page concerns --transport http, and every flag, header, file format and error string is taken from internal/mcp/http.go, the auth_*.go files beside it, internal/sshauth, and the wiring in cmd/secronyx/main.go. Where an older document in the repository disagrees with the code, this page follows the code.
What is protected
| Path | Methods | Authentication |
|---|---|---|
/ |
POST | Required whenever any method is configured. This is the JSON-RPC endpoint. |
/metrics |
GET | Same requirement as /. Metrics name every tool called and every auth failure, so they are not public. |
/health |
GET, HEAD | None. Returns {"status":"ok"} and nothing about configuration. |
/.well-known/oauth-protected-resource |
GET | None. RFC 9728 metadata: resource, scopes_supported, resource_name, resource_description, and authorization_servers when OIDC or introspection is configured. |
Before the listener opens, HTTPConfig.validate applies the exposure rules. They are pure checks and run before any file or socket is touched:
| Listen address | No authentication | Authentication, no TLS | Authentication and TLS |
|---|---|---|---|
127.0.0.1, ::1, localhost |
allowed | allowed | allowed |
anything else, including :8080 and 0.0.0.0 |
refused | refused | allowed |
Two further rules apply regardless of address: a static token shorter than 32 characters (MinBearerTokenLength) is refused, and --tls-client-ca requires both --tls-cert and --tls-key. --allow-unauthenticated (or SECRONYX_ALLOW_UNAUTHENTICATED=1) lifts the two refused cells and logs a SECURITY WARNING at start-up; it does not lift the token-length or client-CA rules. When TLS is on, the listener is TLS 1.2 minimum with only the six ECDHE AEAD suites (AES_128_GCM, AES_256_GCM, CHACHA20_POLY1305 for ECDSA and RSA).
The credential chain
HTTPConfig.Authenticators is an ordered list. main.go builds it in a fixed order from the flags you pass, and then a legacy bearer-token path runs after the list. The complete order is:
| Position | Method (method in audit) |
Configured by | Credential recognised |
|---|---|---|---|
| 1 | api-key |
--api-keys-file |
X-API-Key: <key> or Authorization: ApiKey <key> |
| 2 | ssh-signature |
--ssh-authorized-keys and/or --ssh-ca-keys |
Authorization: SSH-Sig ... |
| 3 | mtls |
--tls-client-ca (with the --mtls-* flags) |
A verified client certificate on the TLS connection |
| 4 | bearer-token |
--token or SECRONYX_TOKEN |
Authorization: Bearer <token> equal to the static token |
| 4 | jwks |
SaaS agent mode (--api-key with --saas-url) |
Authorization: Bearer <JWT> signed by the SaaS JWKS |
| 4 | oidc |
--oidc-issuer and --oidc-audience |
Authorization: Bearer <JWT> from the issuer |
| 4 | oauth-introspection |
--auth-server, --client-id, --client-secret |
Authorization: Bearer <opaque token> |
Positions 1 to 3 are the chain proper. Each Authenticator returns one of three things: (nil, nil) when the request carries no credential of its kind, in which case the chain moves on; an error when a credential of its kind was presented and rejected, in which case the request is denied and nothing later is consulted; or an identity, which is accepted as final. The first authenticator that recognises a credential decides.
Position 4 is authenticateLegacy, reached only when no chain entry recognised anything. It requires an Authorization: Bearer header (missing Authorization header, invalid Authorization header format) and then takes the first configured branch in this order: static token, JWKS, OIDC, introspection. If the chain is exhausted and nothing at position 4 is configured, the denial is no acceptable credentials presented.
Three consequences of this ordering are worth stating plainly, because the older per-method documents describe them differently:
- The static token short-circuits. With
--tokenset, a bearer that does not equal it is refused withinvalid token; OIDC and introspection are never consulted.--tokentherefore cannot be combined usefully with--oidc-issueror--auth-server. - OIDC beats introspection at configuration time. If
--oidc-issueris given,--auth-serveris ignored entirely; both are never active together. - Mutual TLS runs before bearer tokens. When
--tls-client-cais set, themtlsauthenticator is always in the chain, and every request on a connection that carries a client certificate is decided by it. If the certificate maps to an identity (via the identity file or--mtls-default-scopes) that identity is used and any bearer token is ignored. If it does not map, the request is refused before the token is read. The only way a bearer token is reached on an mTLS listener is--tls-client-cert-optionaltogether with a client that presents no certificate. API keys and SSH signatures, by contrast, are consulted beforemtlsand do win over the certificate.
A refusal from any method is a 401 with a bearer challenge, an audit event and a metrics increment:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="secronyx", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
Content-Type: application/json
{"error":"unauthorized","error_description":"api-key: invalid API key"}
Chain errors are prefixed with the authenticator's name, as above. The audit event has action auth/token_validation, result denied, and the error in its metadata; a success carries client_id, scopes and method. See Audit logging. Every 401 also feeds the lockout counter described under Rate limiting and lockout below.
Authentication establishes who; scopes decide what. Every method below yields an Identity{Subject, ClientID, Scopes, Method}, and tools/call is then allowed only if the identity's scopes cover the tool's scope. A grant matches when it is *, the scope name, or mcp:tools:<scope>. A call outside the grant is 200 OK with JSON-RPC error -32003 Forbidden, not a 401. See Scopes and authorization.
Static bearer token
The simplest method: one shared secret, compared for equality against the Bearer value.
export SECRONYX_TOKEN="$(openssl rand -base64 32)"
# Loopback: plaintext HTTP is accepted
secronyx --transport http --listen 127.0.0.1:8080
# Off-host: TLS is mandatory
secronyx --transport http --listen :8443 \
--tls-cert /etc/mcp/cert.pem --tls-key /etc/mcp/key.pem--token and SECRONYX_TOKEN are equivalent; the flag wins if both are set. The token must be at least 32 characters or the server refuses to start. A matching token yields Subject: "bearer-token", ClientID: "static-token", Scopes: ["*"], Method: "bearer-token". The wildcard means a static token can call every registered tool; the only way to narrow it is the registration-time --scopes and --enable-sensitive policy. That is why the static token is a loopback and single-operator tool rather than a fleet credential.
OIDC (JWT validated locally against JWKS)
secronyx --transport http --listen 0.0.0.0:8443 \
--tls-cert /etc/mcp/cert.pem --tls-key /etc/mcp/key.pem \
--server-url https://mcp.example.com \
--oidc-issuer https://enterprise.okta.com \
--oidc-audience secronyx \
--oidc-require-jti \
--oidc-max-token-lifetime 1h| Flag | Default | Effect |
|---|---|---|
--oidc-issuer <url> |
Issuer. Required to enable OIDC. A trailing slash is trimmed. | |
--oidc-audience <s> |
Required with --oidc-issuer. The aud claim (string or array) must contain it. |
|
--oidc-require-jti |
off | Refuse tokens with no jti claim. |
--oidc-max-token-lifetime <d> |
24h |
Refuse tokens whose exp - iat exceeds this. |
What OIDCValidator.ValidateToken checks, in order:
- Signature and header. Algorithm must be
RS256,RS384orRS512; any otheralgis refused. Akidheader is required. The key is looked up in the JWKS cache; on a miss or after the one-hour cache TTL the JWKS is re-fetched. OnlyRSAkeys withuseabsent orsigare loaded. - Discovery. The JWKS URI comes from
<issuer>/.well-known/openid-configuration, fetched once per process with a 10-second timeout. The document'sissuermust equal the configured issuer. Discovery runs undersync.Once: if the first attempt fails, that error is returned for every later token until the server is restarted. - Standard claims.
issmust match;expis required and must be in the future;nbfis honoured when present. - Audience. As above.
- Lifetime. If
iatis present it may be at most five minutes in the future, andexp - iatmust not exceed the cap. Withoutiat,exp - nowmust not exceed the cap. - Replay. If
jtiis present, the pair<issuer>|<jti>is recorded in the replay cache untilexp; a second presentation is refused withtoken replayed (jti already used). Ifjtiis absent and--oidc-require-jtiis off, the token passes with no replay protection.
The identity is built from sub (Subject), client_id or failing that azp (ClientID), and scopes from the first of these claims that is present: scope (space-separated string), scp (array), scopes (array). Method is oidc. No scope is required at the token level; authorization is per tool.
One practical consequence of step 6: a JWT carrying jti is accepted exactly once. There is no result cache on the OIDC path, so a client that reuses one access token across several tools/call requests will be refused from the second request on. Clients on an OIDC listener must obtain a fresh token per request, or the provider must be one that omits jti (with the corresponding loss of replay detection). Plan token issuance accordingly before enabling --oidc-require-jti fleet-wide.
OAuth token introspection
For authorization servers that issue opaque tokens, the server asks the authorization server whether the token is active.
secronyx --transport http --listen 0.0.0.0:8443 \
--tls-cert /etc/mcp/cert.pem --tls-key /etc/mcp/key.pem \
--server-url https://mcp.example.com \
--auth-server https://auth.example.com \
--client-id secronyx --client-secret "$CLIENT_SECRET" \
--introspection-cache-ttl 60s| Flag | Default | Effect |
|---|---|---|
--auth-server <url> |
Authorization server. Ignored if --oidc-issuer is set. |
|
--client-id, --client-secret |
Both required with --auth-server. Sent as HTTP Basic credentials to the introspection endpoint. |
|
--introspection-cache-ttl <d> |
60s |
Reuse a successful introspection for this long. 0 disables the cache. |
--server-url <url> |
http(s)://<listen> |
Used as the resource URL that the token's aud must match. |
The request is POST <auth-server>/introspect with Content-Type: application/x-www-form-urlencoded, body token=<token>, Basic auth, and a 10-second timeout. The endpoint is literally the auth-server URL (trailing slash trimmed) plus /introspect; there is no RFC 8414 discovery on this path. Any non-200 status is a refusal (introspection returned status <n>).
The response is decoded for active, sub, client_id, scope, mcp_scopes, aud, exp and jti, and checked in this order:
activemust betrue(token is not active).aud(string or array, trailing slashes trimmed) must contain the server URL.main.goalways sets the resource URL, so an introspection response with noaudis refused withinvalid token audience. Set--server-urlto exactly what the authorization server puts inaud.- Scopes come from
mcp_scopesif non-empty, else fromscopesplit on whitespace. exp, if positive, must be in the future (token has expired).jti, if present, is recorded asintrospect|<jti>in the replay cache untilexp(or 24 hours whenexpis absent); a second introspection that returns the samejtiis refused as replayed.
The identity is sub, client_id, the scopes, and Method: "oauth-introspection". Successful results are cached under sha256(token) (the token itself is never stored) for the TTL or until the token's exp, whichever is sooner, in a map bounded at 10,000 entries. Inactive tokens and failures are never cached.
Note how steps 5 and the cache interact: while a cached entry is live, requests are served from it and the replay check does not run. Once the entry expires, the token is re-introspected, its jti is already in the replay cache, and the request is refused. In practice a token whose introspection response carries jti is usable for one cache window; with the cache disabled it is single-use. Either issue tokens per session and keep the TTL near the session length, or use an authorization server that does not return jti from introspection.
API keys
API keys give a non-interactive caller its own name, scope list, optional expiry and optional network restriction, with no identity provider involved.
secronyx --transport http --listen 0.0.0.0:8443 \
--tls-cert /etc/mcp/cert.pem --tls-key /etc/mcp/key.pem \
--api-keys-file /etc/secronyx/keys.jsonA key is presented in either of two forms; X-API-Key is checked first:
X-API-Key: msk_3fa9c1e2_<secret>
Authorization: ApiKey msk_3fa9c1e2_<secret>
Key format
msk_<id>_<secret>
msk is a fixed prefix (APIKeyPrefix) so secret scanners can match it. id is eight lowercase hex characters and is the public lookup handle. secret is 32 bytes from crypto/rand, base64url-encoded without padding. Because base64url can itself contain _, only the first two underscores are structural. A key that does not fit this shape is refused as malformed API key before any lookup.
The store holds sha256(secret) only. The comment in auth_apikey.go explains the choice: a salted slow hash protects low-entropy human passwords from offline guessing, but a 256-bit random secret cannot be guessed offline at any hash speed, and a slow hash on a rate-limited endpoint would itself be a denial-of-service lever. The comparison is constant-time and is performed even for an unknown id, so timing does not reveal which ids exist.
The apikey subcommand
secronyx apikey is dispatched before the main flags are parsed and has three actions.
Usage: secronyx apikey <action> [OPTIONS]
Actions:
create Generate a key and add it to the store. The key is printed once.
list Show stored keys (ids, names, scopes, expiry; never secrets).
revoke Disable a key by id.
Options:
--file <path> Key store file (created with mode 0600 if missing)
--name <name> create: human-readable owner, e.g. "ci-runner"
--scopes <list> create: comma-separated tool scopes (default: core)
--expires <dur> create: lifetime such as 90d, 12h, 30m (default: none)
--cidr <list> create: comma-separated CIDRs the key may be used from
--id <id> revoke: key id to disable
secronyx apikey create --file /etc/secronyx/keys.json \
--name ci-runner --scopes core,logs --expires 90d --cidr 10.20.0.0/16Created API key 3fa9c1e2 for "ci-runner" (scopes: core,logs, expires 2026-12-16T09:00:00Z, from 10.20.0.0/16)
This key is shown once and cannot be recovered:
msk_3fa9c1e2_...
--name is required. --expires accepts a Go duration or an integer with a d suffix for days. create refuses a wildcard scope (refusing to issue a wildcard (*) API key; list the scopes explicitly). revoke sets disabled rather than deleting, so the audit history keeps its name. The store is written atomically (<path>.tmp then rename) with mode 0600.
Store file
{
"version": 1,
"keys": [
{
"id": "3fa9c1e2",
"name": "ci-runner",
"hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"scopes": ["core", "logs"],
"created_at": "2026-09-17T09:00:00Z",
"expires_at": "2026-12-16T09:00:00Z",
"disabled": false,
"allowed_cidrs": ["10.20.0.0/16"]
}
]
}| Field | Rules |
|---|---|
version |
Must be 1 (APIKeyStoreVersion). |
id |
Eight lowercase hex characters; unique within the file. |
name |
Becomes the identity Subject. |
hash |
sha256:<64 hex>; any other prefix is unsupported hash format. |
scopes |
Granted scopes. |
created_at |
Informational. |
expires_at |
Optional. Requests at or after this instant are refused (API key <id> expired at <t>). |
disabled |
true refuses the key (API key <id> is revoked). |
allowed_cidrs |
Optional. The client address must fall in one of them (API key <id> is not permitted from this address). |
On Unix the server refuses to load a store whose mode has any group or other read bit set (is readable by other users (mode NNNN); chmod 600 it). The file's modification time is polled at most every two seconds and the store is re-read when it changes, so create and revoke take effect without a restart. A rewrite that fails to parse is logged once and the last good key set stays active.
allowed_cidrs is evaluated against the client IP the transport already resolved, which honours --trust-proxy-headers; without that flag it is the TCP peer, so X-Forwarded-For cannot be used to fake a permitted network. The identity is Subject: <name>, ClientID: "apikey:<id>", Method: "api-key". A worked example is in Tutorial: per-operator API keys.
Mutual TLS
With mTLS the handshake is the credential check: a connection without a certificate that chains to a trusted CA is refused before any HTTP byte is read. The mtls authenticator then decides who the certificate is and what it may do.
secronyx --transport http --listen 0.0.0.0:8443 \
--tls-cert /etc/mcp/server.pem --tls-key /etc/mcp/server-key.pem \
--tls-client-ca /etc/mcp/client-ca.pem \
--mtls-identity-file /etc/mcp/identities.json \
--mtls-require-mapping \
--mtls-trust-domain corp.example \
--mtls-crl /etc/mcp/client-ca.crl| Flag | Effect |
|---|---|
--tls-client-ca <file> |
PEM bundle of CAs. Sets ClientAuth = RequireAndVerifyClientCert: every connection must present a certificate that chains to one of them. Requires --tls-cert and --tls-key. Read once at start-up. |
--tls-client-cert-optional |
Relaxes to VerifyClientCertIfGiven. A connection with no certificate is admitted and must authenticate another way. A migration aid. |
--mtls-identity-file <file> |
JSON mapping from certificates to names and scopes. Requires --tls-client-ca. Re-read when its mtime changes. |
--mtls-default-scopes <list> |
Scopes for a verified certificate that matches no identity entry. Empty (the default) means such certificates are denied. A wildcard is never implied. |
--mtls-require-mapping |
Deny unmapped certificates even when default scopes are set. |
--mtls-trust-domain <list> |
Comma-separated SPIFFE trust domains. When set, a certificate with no spiffe:// URI SAN is denied. |
--mtls-crl <file> |
PEM or DER revocation list. Re-read when its mtime changes. |
What the authenticator checks
Given the leaf certificate from r.TLS.PeerCertificates[0]:
- Extended key usage. If the leaf lists any EKU, it must include
clientAuthorany(client certificate is not valid for client authentication). - Trust domain. If
--mtls-trust-domainis set, the firstspiffe://URI SAN is required and its host part (case-insensitive) must be listed. - Revocation. If
--mtls-crlis set: the CRL is consulted only when its issuer equals the leaf's issuer; when the verified chain provides the issuer certificate, the CRL signature is verified against it; a CRL pastNextUpdateis an error; a listed serial isclient certificate serial <n> is revoked. Errors deny the request rather than skipping the check. - Identity lookup in the identity file, with the precedence below. A
disabledentry denies. No entry and--mtls-require-mappingdenies; no entry and no default scopes denies; otherwise the default scopes apply.
The identity is ClientID: "mtls:<hex SHA-256 of the SubjectPublicKeyInfo>", Method: "mtls", and Subject is the identity-file name or, for a default-scoped certificate, the SPIFFE ID, else the first DNS SAN, else the Common Name, else spki:<fingerprint>.
Identity file
{
"version": 1,
"identities": [
{"match": {"spki_sha256": "9f3c...e1"}, "name": "ci-runner", "scopes": ["core", "logs"]},
{"match": {"spiffe_id": "spiffe://corp.example/ns/ops/sa/triage"}, "name": "triage", "scopes": ["core", "triage"]},
{"match": {"dns": "agent-07.corp.example"}, "name": "agent-07", "scopes": ["core"]},
{"match": {"cn": "Ops Laptop"}, "name": "ops", "scopes": ["core"], "disabled": true},
{"match": {"ou": "SRE"}, "name": "sre-team", "scopes": ["core", "hooks"]}
]
}Rules enforced at load: version must be 1; every entry needs a name; exactly one of spki_sha256, spiffe_id, dns, cn, ou per entry; "*" is not accepted as a scope. Precedence across the whole file is spki_sha256, then spiffe_id, then dns, then cn, then ou, so a key pin always wins over a name. spki_sha256 accepts upper or lower case, with or without a sha256: prefix and colons; spiffe_id and dns match case-insensitively; cn and ou match exactly. Compute the pin with:
openssl x509 -in client.pem -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256The SPKI pin survives certificate renewal with the same key, which is why it is the recommended match for anything long-lived. A file that fails to parse after a successful load keeps the previous entries.
SPIFFE and SPIRE
Workloads attested by SPIRE receive X.509 SVIDs whose URI SAN is spiffe://<trust-domain>/<path>. Point --tls-client-ca at the trust bundle, set --mtls-trust-domain to the domain, and match on spiffe_id. The bundle is read at start-up, so a root rotation needs a restart or an SVID-aware proxy in front. The CRL check is usually unnecessary with SPIRE's short-lived SVIDs.
Combining mTLS with another method
Because mtls sits at position 3, an API key or SSH signature on an mTLS connection is decided first and the certificate is only a transport gate for it. A bearer token, OIDC token or introspected token is not reached while the connection carries a certificate: the certificate must map, or the request is denied. If you want "corporate certificate plus IdP token", the code as written does not provide it; use SSH signatures or API keys as the second factor instead, or run the token-based listener behind a separate TLS-terminating proxy that enforces client certificates.
scripts/gen-mtls-dev-certs.sh produces a throwaway CA, server and client certificate, an empty CRL and a starter identity file for development; the keys are unprotected files on disk and it is labelled development-only. An end-to-end walk-through is in Tutorial: mutual TLS end to end.
SSH request signing
The ssh-signature method reuses keys and certificate authorities that operators already hold for SSH. The client signs a canonical description of each request with its private key; the server verifies the signature against an authorized_keys file or an SSH CA and derives name and scopes from that file. No shared secret crosses the network.
secronyx --transport http --listen 0.0.0.0:8443 \
--tls-cert /etc/mcp/cert.pem --tls-key /etc/mcp/key.pem \
--ssh-authorized-keys /etc/mcp/authorized_keys \
--ssh-ca-keys /etc/mcp/trusted_ca_keysEither file may be omitted, but at least one is required. With only --ssh-ca-keys, bare keys are refused (bare keys are not accepted; present a certificate); with only --ssh-authorized-keys, certificates are refused. Both files are re-read when their mtime or size changes (checked at most every two seconds); a file that no longer parses keeps its last good contents and the error is surfaced through LoadErrors.
Accepted keys: Ed25519, ECDSA P-256/P-384/P-521, RSA of at least 2048 bits, and FIDO security keys (sk-*, via ssh-agent). DSA keys and RSA keys under 2048 bits are refused when the file is parsed. ssh-rsa (SHA-1) and ssh-dss signatures are refused; RSA keys always sign with rsa-sha2-512.
The credential and the canonical string
Authorization: SSH-Sig keyid="SHA256:<43 base64 chars>", ts="<unix seconds>", nonce="<16-64 base64url chars>", sig="<base64 SSH wire signature>"[, cert="<base64 SSH certificate>"]
keyid is the SHA256 fingerprint as printed by ssh-keygen -lf. Every field must be double-quoted, appear once, and the whole header is limited to 16 KiB. The signature covers these seven lines joined by \n:
secronyx-http-sig-v1
<METHOD, upper-cased>
<request URI: path and query exactly as sent>
<Host header, lower-cased>
<ts>
<nonce>
<hex SHA-256 of the request body>
The first line is sshauth.Version; changing the canonical form means changing it, so an old signature can never be replayed against a newer server. Verification refuses a request when ts is more than five minutes (DefaultClockSkew) from the server clock, when the body exceeds 1 MiB (DefaultMaxBodyBytes), when any signed field differs from the request received, or when the <keyid>/<nonce> pair was already seen within the nonce TTL (ten minutes by default, never less than twice the clock skew). The nonce is recorded only after the signature verifies, so a forger cannot burn nonces for a real key. The nonce cache is bounded at 100,000 entries.
authorized_keys options
Standard OpenSSH syntax: optional comma-separated options, key type, key, comment. The comment becomes the caller's Subject; an empty comment falls back to the fingerprint.
scopes="core,logs" ssh-ed25519 AAAAC3Nza... alice@laptop
scopes="core",expires="2026-12-31",from="10.0.0.0/8,192.0.2.7" ecdsa-sha2-nistp256 AAAAE2Vj... ci-runner
| Option | Meaning |
|---|---|
scopes="a,b" |
Scopes granted. A key with no scopes= authenticates but can call nothing. |
expires="YYYY-MM-DD" or RFC 3339 |
The entry stops working after this time; a bare date means the end of that UTC day. |
from="cidr,ip,..." |
Only requests from these addresses are accepted, using the audited client IP (so --trust-proxy-headers applies). |
principals="a,b" |
CA lines only: which certificate principals are accepted. |
Any other option, including command=, fails the whole file with unsupported option "<name>"; a duplicate key also fails it. That is deliberate: an option the server does not understand cannot be relied on to restrict anything, and a corrupt edit must not silently drop a restriction.
Certificates
Put the CA public key in the trusted CA file with the options that apply to everything it signs, then issue user certificates with ssh-keygen -s:
scopes="core,logs",principals="ops,sre" ssh-ed25519 AAAAC3Nza... corp-user-ca
ssh-keygen -s corp-user-ca -I "alice@corp" -n alice,ops -V +8h ~/.ssh/id_ed25519.pubThe verifier requires a user certificate whose key fingerprint equals keyid, with at least one principal, signed by a CA listed in the file. The CA line's expires= and from= apply. The caller's name is the first certificate principal the CA line's principals= permits, or the first principal when the line has no restriction. Validity is checked by ssh.CertChecker, and a source-address critical option in the certificate is enforced against the client IP. Scopes come from the CA line; when the line has no scopes=, the certificate extension secronyx-scopes (comma-separated) is used instead, so a CA can issue scoped certificates:
ssh-keygen -s corp-user-ca -I ci -n ci -V +1d -O extension:secronyx-scopes=core ci.pubThere is no certificate revocation list on this path; revocation is short validity. Revoking a bare key is deleting its line.
The ssh-sign subcommand
secronyx ssh-sign prints the Authorization header, or a complete curl command, for a signed request.
Usage: secronyx ssh-sign --url <url> (--key <path> | --agent [--fingerprint <fp>]) [--cert <path>] [--body <file>|-] [--curl]
-agent
Sign with a key from ssh-agent ($SSH_AUTH_SOCK)
-body string
Request body file, or - for stdin (default: empty body)
-cert string
Path to an SSH certificate (-cert.pub) for the key
-curl
Print a complete curl command instead of just the header
-fingerprint string
SHA256 fingerprint of the agent key to use
-key string
Path to the OpenSSH private key
-method string
HTTP method (default "POST")
-passphrase-env string
Environment variable holding the key passphrase
-url string
Full request URL, e.g. https://mcp.example.com/
printf '%s' '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_cpu_info","arguments":{}}}' > req.json
# Header only
secronyx ssh-sign --key ~/.ssh/id_ed25519 --url https://mcp.example.com/ --body req.json
# Complete curl command, key held in ssh-agent (hardware keys work here)
eval "$(secronyx ssh-sign --agent --fingerprint SHA256:... --url https://mcp.example.com/ --body req.json --curl)"
# With a certificate
secronyx ssh-sign --key ~/.ssh/id_ed25519 --cert ~/.ssh/id_ed25519-cert.pub --url https://mcp.example.com/ --body req.json --curl--key and --agent are mutually exclusive. With --agent and more than one key loaded, --fingerprint is required. A private key file readable by group or others is refused (run chmod 600), and an encrypted key needs --passphrase-env naming the variable that holds its passphrase. The body passed to --body must be byte-for-byte what is sent, because its digest is signed; the generated curl uses --data-binary for that reason. The signature is valid for the clock-skew window, so sign immediately before sending. Go callers can use sshauth.Sign directly. The identity is Subject: <comment or principal>, ClientID: "ssh:<fingerprint>", Method: "ssh-signature". See Tutorial: SSH key request signing.
How each method carries scopes
| Method | Source of the scope list | Wildcard possible? |
|---|---|---|
bearer-token |
Always ["*"] |
Yes, always |
jwks (SaaS) |
JWT scope, scp or scopes claim |
If the SaaS issues it |
oidc |
JWT scope, scp or scopes claim |
If the issuer grants it |
oauth-introspection |
mcp_scopes, else scope from the introspection response |
If the authorization server grants it |
api-key |
scopes in the key record |
Refused by apikey create; editable by hand |
mtls |
Identity-file scopes, else --mtls-default-scopes |
Refused in the identity file; allowed on the flag |
ssh-signature |
scopes= on the key or CA line, else the secronyx-scopes certificate extension |
Only if written into the file or certificate |
In every case the grant is then matched against the tool's scope as *, <scope>, or mcp:tools:<scope>; the bare audience value mcp:tools grants nothing. Tools in the sensitive scope are only callable if the server was also started with --enable-sensitive, whatever the identity holds.
Rate limiting and lockout
Rate limiting is on by default (--rate-limit, disable with --rate-limit=false, which logs WARNING: HTTP rate limiting and brute-force lockout are DISABLED). It is installed as the outermost middleware, so a rejected request is answered before logging, metrics, authentication or any tool code run. The defaults come from DefaultRateLimitConfig and are not tunable from the command line:
| Control | Default | Response |
|---|---|---|
| Sustained rate, per client | 20 requests/s | 429 with Retry-After |
| Burst, per client | 40 requests | |
| In-flight requests, global | 32 | 503 server busy with Retry-After: 1 |
| Lockout threshold | 10 401 responses within the window |
429 for the lockout duration |
| Lockout window and duration | 15 minutes | |
| Tracked client addresses | 10,000, LRU-evicted |
The client address is the TCP peer, or the first X-Forwarded-For entry (falling back to X-Real-IP) when --trust-proxy-headers is set. The same flag drives the audit client IP, API-key allowed_cidrs and SSH from=, so set it only when the listener is reachable solely through a proxy you control; otherwise a caller can forge all four. The lockout counts every 401 regardless of method, so guessing API keys, tokens, JWTs and SSH signatures all feed one counter per address.
Replay protection
Three independent mechanisms exist, and which one protects a request depends on the method:
| Method | Mechanism | Window |
|---|---|---|
oidc |
ReplayCache keyed <issuer>|<jti> |
Until the token's exp. A token without jti is not replay-checked unless --oidc-require-jti makes it fail instead. |
oauth-introspection |
ReplayCache keyed introspect|<jti> |
Until exp, or 24 hours if the response has no exp. Checked only when the introspection call is made, not on a cache hit. |
ssh-signature |
Nonce cache keyed <keyid>/<nonce> plus the five-minute timestamp window |
Ten minutes, or twice the clock skew if larger. |
bearer-token, api-key, mtls |
None at the request level | These credentials are meant to be reused; protect them with TLS, expiry, allowed_cidrs, from= and revocation. |
The two ReplayCache instances are bounded at 100,000 entries (DefaultReplayCacheMaxEntries); when full, expired entries are dropped first and then the oldest live entry, so a flood of unique identifiers degrades detection for the oldest tokens rather than growing memory. Both the JWT and introspection checks make a token single-use per validation, as described in their sections above.
Recommended combinations
| Deployment | Configure | Rationale |
|---|---|---|
| Laptop or single host, MCP client on the same machine | stdio transport, or --listen 127.0.0.1:8080 with --token from SECRONYX_TOKEN |
No network exposure. The token is only needed if a local HTTP client is in use; it carries * so rely on --scopes to narrow tools. |
| Data centre, a handful of operators and some automation | --tls-cert/--tls-key, --ssh-authorized-keys with scopes= and from= for people, --api-keys-file with --expires and --cidr for automation, rate limiting left on |
Reuses SSH keys operators already hold, gives every caller a name in the audit log, and lets revocation be a file edit picked up in two seconds. No IdP dependency on the diagnostic path. |
| Data centre with a corporate IdP and short-lived tokens | --oidc-issuer/--oidc-audience with --oidc-max-token-lifetime at or below the session length, plus --api-keys-file or --ssh-ca-keys for non-interactive callers |
People authenticate through the IdP; because a jti token is single-use, the client must mint a token per request or the IdP must omit jti. Automation stays on keys that do not depend on the IdP being up. |
| Zero-trust fleet or service mesh | --tls-client-ca (SPIRE bundle), --mtls-trust-domain, --mtls-identity-file matching on spiffe_id or spki_sha256, --mtls-require-mapping; add --mtls-crl when certificates live longer than a day |
Workload identity is proven at the handshake, unmapped workloads are denied, and a key pin cannot be borrowed by a certificate with a familiar name. Add --ssh-ca-keys if humans also need in, since SSH signatures win over the certificate mapping. |
| Behind a TLS-terminating reverse proxy | --listen 127.0.0.1:<port>, any credential method, --trust-proxy-headers only if the proxy sets X-Forwarded-For |
The loopback bind satisfies the exposure rules without a second certificate; the proxy flag keeps audit IPs, lockout buckets and CIDR pins honest. |
| Managed through the SaaS control plane | --api-key with --saas-url (agent mode) or hybrid mode |
The agent registers, auto-generates its certificate and validates SaaS-issued JWTs from the returned JWKS URL; see Hybrid and SaaS mode. |
Combinations to avoid: --token alongside --oidc-issuer or --auth-server (the token short-circuits them); --tls-client-ca alongside bearer or OIDC tokens as a "second factor" (the certificate is decided first and the token is never read); --allow-unauthenticated anywhere a routable address is bound; and --rate-limit=false on any non-loopback listener.
Related pages: Security model, Scopes and authorization, Audit logging, Remote access over HTTP, Configuration reference, Troubleshooting, and the documentation index.
Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.