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

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 --token set, a bearer that does not equal it is refused with invalid token; OIDC and introspection are never consulted. --token therefore cannot be combined usefully with --oidc-issuer or --auth-server.
  • OIDC beats introspection at configuration time. If --oidc-issuer is given, --auth-server is ignored entirely; both are never active together.
  • Mutual TLS runs before bearer tokens. When --tls-client-ca is set, the mtls authenticator 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-optional together with a client that presents no certificate. API keys and SSH signatures, by contrast, are consulted before mtls and 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:

  1. Signature and header. Algorithm must be RS256, RS384 or RS512; any other alg is refused. A kid header 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. Only RSA keys with use absent or sig are loaded.
  2. Discovery. The JWKS URI comes from <issuer>/.well-known/openid-configuration, fetched once per process with a 10-second timeout. The document's issuer must equal the configured issuer. Discovery runs under sync.Once: if the first attempt fails, that error is returned for every later token until the server is restarted.
  3. Standard claims. iss must match; exp is required and must be in the future; nbf is honoured when present.
  4. Audience. As above.
  5. Lifetime. If iat is present it may be at most five minutes in the future, and exp - iat must not exceed the cap. Without iat, exp - now must not exceed the cap.
  6. Replay. If jti is present, the pair <issuer>|<jti> is recorded in the replay cache until exp; a second presentation is refused with token replayed (jti already used). If jti is absent and --oidc-require-jti is 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:

  1. active must be true (token is not active).
  2. aud (string or array, trailing slashes trimmed) must contain the server URL. main.go always sets the resource URL, so an introspection response with no aud is refused with invalid token audience. Set --server-url to exactly what the authorization server puts in aud.
  3. Scopes come from mcp_scopes if non-empty, else from scope split on whitespace.
  4. exp, if positive, must be in the future (token has expired).
  5. jti, if present, is recorded as introspect|<jti> in the replay cache until exp (or 24 hours when exp is absent); a second introspection that returns the same jti is 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.json

A 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/16
Created 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]:

  1. Extended key usage. If the leaf lists any EKU, it must include clientAuth or any (client certificate is not valid for client authentication).
  2. Trust domain. If --mtls-trust-domain is set, the first spiffe:// URI SAN is required and its host part (case-insensitive) must be listed.
  3. Revocation. If --mtls-crl is 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 past NextUpdate is an error; a listed serial is client certificate serial <n> is revoked. Errors deny the request rather than skipping the check.
  4. Identity lookup in the identity file, with the precedence below. A disabled entry denies. No entry and --mtls-require-mapping denies; 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 -sha256

The 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_keys

Either 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.pub

The 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.pub

There 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.

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.