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

Tutorials

Tutorial: per-operator API keys

Issue, scope, expire, CIDR-pin, rotate and revoke API keys with the apikey subcommand, call the HTTP transport with them, and read what the server records for every accepted and rejected key.

API keys give each operator, CI job or automation its own name, scope list, expiry and network restriction without an identity provider. This walkthrough builds a key store for Secronyx from nothing, exercises every acceptance and rejection path, and finishes with rotation and the brute-force lockout. The transcripts below were captured from a run of the current build. The reference pages are Command line for the subcommand, Authentication for where keys sit in the credential chain, and Scopes and authorization for what a scope grants.

One naming trap before starting: --api-key (singular) selects the SaaS agent and hybrid modes described under Hybrid and SaaS mode. The per-operator store in this tutorial is --api-keys-file, and keys for it are managed with secronyx apikey.

1. Issue the first key

The store is created on first use with mode 0600. --name is required; --scopes defaults to core; --expires takes a Go duration (12h, 30m) or a day count (90d); --cidr takes a comma-separated list of networks the key may be used from.

mkdir -p ~/keys-lab && cd ~/keys-lab

secronyx apikey create --file keys.json --name alice \
  --scopes core,logs --expires 90d --cidr 127.0.0.0/8
Created API key edd65a6a for "alice" (scopes: core,logs, expires 2026-12-16T16:14:12Z, from 127.0.0.0/8)
This key is shown once and cannot be recovered:
msk_edd65a6a_bofGnMcTtvka_11yQCDjRFxo73M37AKGDMyC2Tmrla8

Hand the last line to Alice through a channel you would trust with a password. It is not stored anywhere: the file keeps sha256(secret), and there is no apikey show.

The key has three parts separated by underscores: the fixed prefix msk, which is the same on every issued key and so is easy to give to a secret scanner; an 8-character lowercase-hex id used for lookup; and a 32-byte random secret in unpadded base64url. The secret may itself contain underscores, as this one does; only the first two separators are structural.

Add a second key for a CI job with the defaults, and see the guard rails:

secronyx apikey create --file keys.json --name ci-runner --expires 12h
# Created API key 0183f949 for "ci-runner" (scopes: core, expires 2026-09-18T04:14:12Z)
# This key is shown once and cannot be recovered:
# msk_0183f949_lA3dk0xg4CpWswYAY-iEtIanyk2YDc51QgXZPmtaEIY

secronyx apikey create --file keys.json --name bad --scopes '*'
# Error: refusing to issue a wildcard (*) API key; list the scopes explicitly
secronyx apikey create --file keys.json --name bad --expires 3w
# Error: invalid lifetime "3w"
secronyx apikey create --file keys.json --name bad --cidr 10.0.0.1
# Error: invalid CIDR "10.0.0.1": invalid CIDR address: 10.0.0.1

A bare address is not a CIDR; write 10.0.0.1/32.

2. Look at the store

secronyx apikey list --file keys.json
ID         NAME                 STATUS     CREATED                EXPIRES                SCOPES
edd65a6a   alice                active     2026-09-17T16:14:12Z   2026-12-16T16:14:12Z   core,logs
0183f949   ci-runner            active     2026-09-17T16:14:12Z   2026-09-18T04:14:12Z   core

A key with no expiry shows - in the EXPIRES column. The file itself is plain JSON, written atomically through keys.json.tmp and a rename:

ls -l keys.json
# -rw------- 1 ops ops 717 Sep 17 17:14 keys.json
cat keys.json
{
  "version": 1,
  "keys": [
    {
      "id": "edd65a6a",
      "name": "alice",
      "hash": "sha256:8cbc656fdf45d659570fa0086f6739a8d8c3c93426e9943f6bf9210d5e22f912",
      "scopes": [
        "core",
        "logs"
      ],
      "created_at": "2026-09-17T16:14:12Z",
      "expires_at": "2026-12-16T16:14:12Z",
      "disabled": false,
      "allowed_cidrs": [
        "127.0.0.0/8"
      ]
    },
    {
      "id": "0183f949",
      "name": "ci-runner",
      "hash": "sha256:b5c85b06d2c8a531b2cd96d934c1afdd03e1a3f2cd91eb70e9e22634030e624a",
      "scopes": [
        "core"
      ],
      "created_at": "2026-09-17T16:14:12Z",
      "expires_at": "2026-09-18T04:14:12Z",
      "disabled": false
    }
  ]
}

expires_at and allowed_cidrs are omitted when unset. You may edit this file by hand, for example to add a network to allowed_cidrs, but the loader validates it: version must be 1, ids must be 8 lowercase-hex characters and unique, each hash must be sha256: followed by 64 hex characters, and every CIDR must parse. A file that fails validation is refused at startup, and on reload it is ignored in favour of the key set already in memory.

3. Start the server

For the lab, bind to loopback without TLS. A loopback listener is exempt from the transport's TLS requirement; any other address needs --tls-cert/--tls-key unless you pass --allow-unauthenticated.

secronyx --transport http --listen 127.0.0.1:8099 \
  --api-keys-file keys.json --audit-output ./audit.jsonl
2026/09/17 17:14:22 Audit logging enabled: provider=default output=./audit.jsonl
2026/09/17 17:14:22 Scope policy: 9 tools not registered (sensitive=false, scopes="")
2026/09/17 17:14:22 MCP HTTP Server starting on 127.0.0.1:8099
2026/09/17 17:14:22   Server URL: http://127.0.0.1:8099
2026/09/17 17:14:22   Auth:       api-key

Two startup checks protect the store. It must not be readable by other users:

Error loading API keys: API key store "keys.json" is readable by other users (mode 0644); chmod 600 it

And a listener on a non-loopback address must have TLS, because the key crosses the network in a header:

Error: refusing to listen on "0.0.0.0:8443" with authentication but without TLS: credentials would cross the network in plaintext; add --tls-cert/--tls-key, bind to 127.0.0.1 behind a TLS-terminating proxy, or pass --allow-unauthenticated for an isolated development network

The production form is therefore:

secronyx --transport http --listen 0.0.0.0:8443 \
  --server-url https://mcp.example.internal:8443 \
  --tls-cert /etc/secronyx/server.pem --tls-key /etc/secronyx/server-key.pem \
  --api-keys-file /etc/secronyx/keys.json

See Remote access over HTTP for certificates and proxies.

4. Call it

Either header form is accepted. X-API-Key is checked first; if both are present it wins.

KEY=msk_edd65a6a_bofGnMcTtvka_11yQCDjRFxo73M37AKGDMyC2Tmrla8
URL=http://127.0.0.1:8099/

curl -s -X POST $URL -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
# 13

curl -s -X POST $URL -H "Authorization: ApiKey $KEY" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_uptime","arguments":{}}}' | cut -c1-140
# {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n  \"boot_time\": \"2026-08-27T21:20:57.803937048+01:00\",\n  \"uptime

Alice holds core and logs, so tools/list returns the 13 tools in those two scopes rather than the 521 the server registered under the default policy. A tool in another scope is refused after authentication, at the JSON-RPC layer:

curl -s -X POST $URL -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_listening_ports","arguments":{}}}'
# {"jsonrpc":"2.0","id":3,"error":{"code":-32003,"message":"Forbidden","data":"tool \"get_listening_ports\" requires scope \"hooks\""}}

Scope names are the ones on the Tool reference. A key can only ever call tools the server registered; sensitive tools need --enable-sensitive on the server as well as the scope on the key.

5. Every way a key is rejected

Each rejection is an HTTP 401 with a JSON body whose error_description gives the reason, prefixed with the name of the authenticator that rejected the credential (api-key). A request that presents no credential at all is refused by the transport before any authenticator runs, so it has no prefix. Every 401 also carries the same WWW-Authenticate challenge.

curl -s -i -X POST $URL -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":4,"method":"tools/list"}'
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"
Date: Thu, 17 Sep 2026 16:14:34 GMT
Content-Length: 83

{"error":"unauthorized","error_description":"no acceptable credentials presented"}
curl -s -X POST $URL -H 'X-API-Key: msk_edd65a6a_notthesecret' -d '{}'
# {"error":"unauthorized","error_description":"api-key: invalid API key"}

curl -s -X POST $URL -H 'X-API-Key: hello' -d '{}'
# {"error":"unauthorized","error_description":"api-key: malformed API key"}

invalid API key covers both an unknown id and a wrong secret. The hash comparison is constant-time and runs even when the id does not exist, so response timing does not reveal which ids are in the store.

A key with allowed_cidrs is refused from anywhere else. The address checked is the one the server resolved for the audit log: the TCP peer, or the first X-Forwarded-For entry (or X-Real-IP) only when --trust-proxy-headers is set. Without that flag a caller cannot forge a permitted network with a header.

secronyx apikey create --file keys.json --name wrong-network --scopes core --cidr 10.0.0.0/8
curl -s -X POST $URL -H "X-API-Key: msk_8681c2b0_..." -d '{}'
# {"error":"unauthorized","error_description":"api-key: API key 8681c2b0 is not permitted from this address"}

Expiry is checked on every request against the server clock; at or after expires_at the key is dead:

{"error":"unauthorized","error_description":"api-key: API key 660a4224 expired at 2026-09-17T16:14:45Z"}

apikey list shows such a key as expired, and a disabled one as revoked.

6. Rotate and revoke

Rotation is create-then-revoke, so the caller is never without a working key. The server polls the store's modification time at most every 2 seconds and reloads it, so neither step needs a restart.

secronyx apikey create --file keys.json --name alice --scopes core,logs --expires 90d --cidr 127.0.0.0/8
# Created API key c638f334 for "alice" (...)
# deliver the new key, switch Alice's tooling to it, then:
secronyx apikey revoke --file keys.json --id edd65a6a
# revoked key edd65a6a (alice)

Two seconds later the old key fails closed, and the audit trail keeps its history under the same name:

curl -s -X POST $URL -H "X-API-Key: $KEY" -d '{}'
# {"error":"unauthorized","error_description":"api-key: API key edd65a6a is revoked"}

secronyx apikey revoke --file keys.json --id edd65a6a
# key edd65a6a was already revoked
secronyx apikey revoke --file keys.json --id deadbeef
# Error: no key with id "deadbeef"

Revocation sets "disabled": true and leaves the record in place. Delete records only when their audit history is no longer needed for correlation.

If a hand edit breaks the file while the server runs, the previous key set stays active and the server logs once:

2026/09/17 17:15:02 WARNING: API key store "keys.json": invalid character '}' looking for beginning of value; keeping previously loaded API keys

7. See what brute force looks like

Rate limiting is on by default and runs before authentication. Ten 401 responses from one client address within fifteen minutes lock that address out for fifteen minutes, with 429 and a Retry-After header. The lockout is keyed by address, not by key, so a valid key from the same address is refused too until the window passes.

for i in $(seq 1 12); do
  curl -s -o /dev/null -w '%{http_code} retry-after=%header{retry-after}\n' \
    -X POST $URL -H 'X-API-Key: msk_0183f949_wrongsecret' -d '{}'
done
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
401 retry-after=
429 retry-after=900
429 retry-after=900

The token bucket in front of it allows 20 requests per second with a burst of 40 per client, and 32 requests in flight globally (503 with Retry-After: 1). --rate-limit=false turns all of this off and logs WARNING: HTTP rate limiting and brute-force lockout are DISABLED. Behind a reverse proxy, set --trust-proxy-headers so both the lockout and allowed_cidrs see the real client address instead of the proxy's; the rest is on Security model.

8. Read the trail

Every request is authenticated independently, so an auth/token_validation event precedes each tools/call. Successes carry the key's name as identity and apikey:<id> as the client ID; failures carry the reason. tools/list produces its authentication event but is not itself audited.

jq -c '{seq,action,resource,identity,result,error,m:.metadata}
       | with_entries(select(.value != null))' audit.jsonl
{"seq":1,"action":"auth/token_validation","identity":"alice","result":"success","m":{"client_id":"apikey:edd65a6a","method":"api-key","scopes":["core","logs"]}}
{"seq":2,"action":"auth/token_validation","identity":"alice","result":"success","m":{"client_id":"apikey:edd65a6a","method":"api-key","scopes":["core","logs"]}}
{"seq":3,"action":"tools/call","resource":"get_uptime","identity":"alice","result":"success"}
{"seq":4,"action":"auth/token_validation","identity":"alice","result":"success","m":{"client_id":"apikey:edd65a6a","method":"api-key","scopes":["core","logs"]}}
{"seq":5,"action":"tools/call","resource":"get_listening_ports","identity":"alice","result":"denied","error":"scope denied"}
{"seq":6,"action":"auth/token_validation","result":"denied","m":{"error":"no acceptable credentials presented"}}
{"seq":7,"action":"auth/token_validation","result":"denied","m":{"error":"api-key: invalid API key"}}
{"seq":8,"action":"auth/token_validation","result":"denied","m":{"error":"api-key: malformed API key"}}
{"seq":9,"action":"auth/token_validation","result":"denied","m":{"error":"api-key: API key 8681c2b0 is not permitted from this address"}}
{"seq":10,"action":"auth/token_validation","result":"denied","m":{"error":"api-key: API key 660a4224 expired at 2026-09-17T16:14:45Z"}}
{"seq":11,"action":"auth/token_validation","result":"denied","m":{"error":"api-key: API key edd65a6a is revoked"}}

Counters for the same events are on /metrics, which requires the same credentials as the MCP endpoint (give the full path, not a URL with a trailing slash, or the mux answers 301):

curl -s http://127.0.0.1:8099/metrics -H "X-API-Key: msk_0183f949_..." \
  | grep secronyx_auth_requests_total

Read that counter as an attempt counter rather than a request counter: a request with a good key adds one to result="success", while a rejected API key is counted twice, once by the authenticator and once by the transport. secronyx_http_requests_total is the one that matches responses one for one.

Requests that are locked out are answered by the rate limiter before the rest of the chain, so they reach neither the authenticator nor the metrics middleware: they produce no audit event, no auth counter and no entry in secronyx_http_requests_total. Tutorial: verify the audit chain shows how to prove the file above is intact.

Operating guidance

  • One key per person or job, named so the audit identity is meaningful on its own. Never share a key between a human and an automation.
  • Always set --expires. Ninety days suits automation; shorter for anything holding security, windows or sensitive.
  • Pin --cidr for jobs that run from a known network. Pair it with --trust-proxy-headers only when a trusted proxy is the sole path to the listener.
  • Put the key in an environment variable or a secret store in CI and reference it from there; every issued key starts with msk_, which is what you give a secret scanner to find keys leaked into logs and repositories.
  • The store contains only hashes, but its ids and scope map are useful to an attacker. Keep it 0600, owned by the service user, and out of backups that are less protected than the host.
  • apikey create refuses to issue a key whose scope list contains *. For an identity that needs everything, list the scopes; the Scopes and authorization page explains why the registration policy on the server is the stronger control.

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