Skip to content
docs
Arxo ↗

Authentication and access control

For LLMs15 sections

Say exactly which access schemes the servers implement, who “the client” is at each layer, where secrets live and how they rotate — and name the integrations that do not exist, so no operator plans around them.

Components: law-mcp-server --http (law-mcp/src/http.rs, src/main.rs) and law serve (law-serve-core/src/service.rs, law-bin-core/src/serve.rs). Scenario: a private or public HTTP deployment. All tokens, users, and paths below are synthetic created-examples. “Implemented / described / verified / supported” follow the binding section vocabulary in Verification and support matrix; “verified absence” means a code search found no such mechanism.

BranchCoverage in this article
MCP (law-mcp-server --http)token check, public-bind refusal, rotation procedure
law servetoken check via --token-file, same rotation shape (replace file, restart)

Both branches share the limits below (one token per process, no IdP, no per-user rights).

There are exactly three, and they are the same on both servers:

#SchemeMCP settinglaw serve settingEffect
1Bearer tokenLAW_MCP_TOKEN / --token--token / --token-fileevery request must carry Authorization: Bearer <token>, exact match; otherwise 401
2Loopback-implicitbind 127.0.0.1/::1/localhost with no tokensameno Authorization needed; the bind address is the control
3Explicit publicLAW_MCP_PUBLIC=1 / --public--publicdeliberate unauthenticated bind on any address

Details verified in code:

  • MCP 401 responses carry WWW-Authenticate: Bearer resource="law-mcp". law serve answers 401 with code SERVE_UNAUTHORIZED (“missing or wrong Authorization: Bearer”).
  • Scheme 1 vs 2 vs 3 is chosen at startup; a non-loopback bind with no token and no public flag refuses to start (exit 2) on both servers — an open evaluator never comes up by typo.
  • law serve forbids --token together with --token-file, and refuses an empty token file. MCP has no --token-file: the secret arrives via environment (see Storage below).
  • There is exactly one token per process: no per-client, per-tool, or per-case credentials exist in code.

Client identity vs operation right vs case right

Section titled “Client identity vs operation right vs case right”
LayerQuestion it answersMechanismStrength
Client identitywhich caller is this?none. The journal records MCP_CLIENT (first X-Forwarded-For element, else socket peer) for observability only; it enforces nothingnot an identity — trusted-by-layout logging
Operation rightmay this caller use the instance?the Bearer token (scheme 1), the loopback bind (scheme 2), or explicit public (scheme 3)the only enforced check
Case rightmay this caller see case X / predicate Y?none. Any caller passing the operation check may call any allowed-facet tool with arbitrary factsnot a boundary — do not rely on one

Consequences: two tenants sharing one token share everything the profile serves; callers are distinguishable only in logs, never in authorization. Tenant separation is per-process (separate ports, tokens, profiles), which is how the shipped inventory runs one instance per slice.

MCP (real flags, synthetic values; one shell variable is the single source for both the server and the positive probe, so they cannot drift apart):

Terminal
TOKEN='REDACTED_EXAMPLE_TOKEN_32'
LAW_MCP_TOKEN="$TOKEN" law-mcp-server --http --port 8722 &
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8722/mcp \
-H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
curl -s http://127.0.0.1:8722/mcp -H 'content-type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'

law serve (real flags, synthetic paths):

Terminal
# Private directory first: a fixed /tmp file inherits umask (0644 at
# umask 022), keeps the mode of a pre-existing file, and follows a
# planted symlink. mktemp -d is atomic and 0700; the token file is
# created fresh inside it with umask 077, so it is 0600.
TOKDIR=$(mktemp -d /tmp/serve-token.XXXXXX) || exit 1
[ -n "$TOKDIR" ] && [ -d "$TOKDIR" ] || { echo "mktemp failed" >&2; exit 1; }
(umask 077; printf '%s' 'REDACTED_EXAMPLE_TOKEN_32' > "$TOKDIR/token")
ls -l "$TOKDIR/token" # expect: -rw------- (600)
[ -f "$TOKDIR/token" ] || { echo "token file missing" >&2; exit 1; }
law serve --world /srv/cases/kompaniya --journal /srv/cases/kompaniya-journal \
--token-file "$TOKDIR/token" &
SERVE_PID=$!
# Remove the directory only after the server stops: the running
# process holds the path. The trap covers Ctrl-C and errors; a
# normal stop path kills the server first, then cleans up.
cleanup_tokdir() { kill "$SERVE_PID" 2>/dev/null; wait "$SERVE_PID" 2>/dev/null; rm -rf "$TOKDIR"; }
trap cleanup_tokdir EXIT INT TERM
  • The first MCP curl prints 401; the second returns HTTP 200 with the ping result.
  • law serve starts only with --token/--token-file on a non-loopback bind; with the file in place it serves after workers warm up.
  • Wrong token → 401; no token where one is configured → 401.
  • Restart the process without the token on loopback → requests pass (scheme 2); on 0.0.0.0 → startup refusal (exit 2).
  • Unit-level negative test (private template): point the private unit at an EnvironmentFile with the token line removed and start it — ExecStartPre fails and the service never listens (the binary alone would serve on loopback, so the guard, not the bind address, enforces “no token, no service”). Binary-level proof of the deeper rule (verified): --host 0.0.0.0 with neither token nor public flag exits 2 with the refusal naming LAW_MCP_TOKEN. The unit adds no PUBLIC line that could downgrade this refusal into an open bind (check-conformance.sh asserts the absence statically).
  • The token value must never appear in ps output when passed via env/file; --token on a command line is visible — prefer the file.

Reference-setting (kit unit kit/secure-deploy/law-mcp-private@.service — the private template, which contains no PUBLIC line):

  • Per-instance secrets live in /etc/arxo/mcp-<port>.env (the unit’s EnvironmentFile, mode 600). Files are root-owned; the process runs as User=arxo-mcp with NoNewPrivileges=true.
  • Rotation is replace-then-restart: the token is read once at startup, so editing the file alone changes nothing. Restart is scoped by sudoers to exactly /usr/bin/systemctl restart law-mcp-private@<port> for the deploy user.
  • Fail-closed rule: with no usable token in that file, the private unit must not serve — the process exits instead of opening up (negative test in Result check). The shipped public unit (with LAW_MCP_PUBLIC=1) is a separate deliberate choice, never the fallback for a missing secret.

Rotation procedure (real commands, synthetic port and token). The env file holds more than the token, so update only the token line — never truncate the file. One helper does the whole edit so a read or write failure aborts before the live file is touched; the old token needed for the post-check is staged before activation (a staging failure aborts, never warns-and-continues); its only output names the changed key, never a value. The tested source is kit/bin/arxo-rotate-token next to this page (run sh kit/tests/test-rotate-token.sh); install that exact file once as /usr/local/sbin/arxo-rotate-token (root-owned, mode 755; Linux coreutils for --reference):

Supported env shape (narrow, enforced by the helper): exactly one KEY=value line for the rotated key, with the value using only A-Z a-z 0-9 . _ ~ - — no quoting, no whitespace, no backslashes. A quoted systemd value such as KEY="abc" is valid for systemd but is refused here: normalize it by hand before rotating, or the helper aborts without touching the file. Every pre-activation check must exit successfully; a failed read is never treated as “no differences”.

Terminal
#!/bin/sh
# Usage: printf '%s\n' "$NEW_TOKEN" | sudo arxo-rotate-token <env-file> <KEY>
#
# Rotates one KEY=value line in a systemd EnvironmentFile, changing
# nothing else. Supported file format (narrow, enforced below):
# - exactly one line starting with "KEY=";
# - the value uses only A-Z a-z 0-9 . _ ~ - (no quoting, no spaces,
# no backslashes, no empty continuation);
# - every other line is preserved byte-identically.
# Anything outside this shape aborts before the live file is touched.
# The previous value is staged for the post-check BEFORE activation;
# a staging failure aborts the rotation (never warns-and-continues).
# Needs Linux coreutils for chmod/chown --reference.
set -u
ENV=$1; KEY=$2
[ -f "$ENV" ] || { echo "rotate: not a file: $ENV" >&2; exit 2; }
[ -r "$ENV" ] || { echo "rotate: cannot read $ENV" >&2; exit 2; }
case $KEY in
''|*[!A-Za-z0-9_]*)
echo "rotate: bad key name: $KEY" >&2; exit 2 ;;
esac
case $KEY in [0-9]*) echo "rotate: bad key name: $KEY" >&2; exit 2 ;; esac
IFS= read -r NEW || { echo "rotate: no token on stdin" >&2; exit 2; }
case $NEW in
''|*[!A-Za-z0-9._~-]*)
echo "rotate: bad token (empty or outside A-Z a-z 0-9 . _ ~ -)" >&2
exit 2 ;;
esac
# Exactly one KEY= line; its value must already be in the narrow
# alphabet (a quoted systemd value is valid for systemd but NOT
# supported here — normalize it by hand first).
OLD_LINE=''
KEY_COUNT=0
while IFS= read -r line || [ -n "$line" ]; do
case $line in "$KEY="*)
OLD_LINE=$line; KEY_COUNT=$((KEY_COUNT + 1)) ;;
esac
done < "$ENV"
[ "$KEY_COUNT" -eq 1 ] || { echo "rotate: want exactly one $KEY line, found $KEY_COUNT" >&2; exit 2; }
OLD_VAL=${OLD_LINE#*=}
case $OLD_VAL in
''|*[!A-Za-z0-9._~-]*)
echo "rotate: unsupported $KEY value (quoting or outside A-Z a-z 0-9 . _ ~ -); normalize manually" >&2
exit 2 ;;
esac
NEW_LINE="$KEY=$NEW"
[ "$NEW_LINE" != "$OLD_LINE" ] || { echo "rotate: new equals old; abort" >&2; exit 2; }
# Stage the genuinely-previous token BEFORE activation: if this fails,
# the rotation aborts with the live file untouched, instead of
# reporting success without the value the post-check needs.
# ARXO_ROTATE_STAGED overrides the path for tests only.
STAGED="${ARXO_ROTATE_STAGED:-/run/arxo-rotated-out-$(basename "$ENV")}"
TMP=""
SUCCESS=0
cleanup() {
if [ -n "$TMP" ]; then rm -f "$TMP"; fi
if [ "$SUCCESS" != 1 ]; then rm -f "$STAGED"; fi
}
trap 'cleanup' EXIT INT TERM
umask 077
# One terminated line: the post-check reads the staged value with
# `mkheader` (`IFS= read -r`), which only succeeds on a completed
# line. Bare bytes without the newline would fail the check, so the
# writer — not the reader — owns the format.
printf '%s\n' "$OLD_VAL" > "$STAGED" \
|| { echo "rotate: cannot stage old token; abort" >&2; exit 2; }
[ -s "$STAGED" ] || { echo "rotate: staged old token empty; abort" >&2; exit 2; }
DIR=$(dirname "$ENV")
TMP=$(mktemp "$DIR/.rotate.XXXXXX") || { echo "rotate: mktemp failed" >&2; exit 2; }
while IFS= read -r line || [ -n "$line" ]; do
case $line in "$KEY="*) ;; *) printf '%s\n' "$line" >> "$TMP" ;; esac
done < "$ENV"
printf '%s\n' "$NEW_LINE" >> "$TMP" || { echo "rotate: write failed" >&2; exit 2; }
# Fail-closed backstop: everything except the rotated line must be
# byte-identical. grep may exit 1 when nothing matches (env holding
# only the key line); that is success with empty output. Any other
# non-zero status is a failed check and aborts before mv.
old_rest=$(grep -v "^$KEY=" "$ENV"); old_st=$?
[ "$old_st" -eq 0 ] || [ "$old_st" -eq 1 ] || { echo "rotate: verify read failed ($old_st)" >&2; exit 2; }
new_rest=$(grep -v "^$KEY=" "$TMP"); new_st=$?
[ "$new_st" -eq 0 ] || [ "$new_st" -eq 1 ] || { echo "rotate: verify read failed ($new_st)" >&2; exit 2; }
[ "$old_rest" = "$new_rest" ] || { echo "rotate: non-token content differs; abort" >&2; exit 2; }
grep -q "^$KEY=" "$TMP" || { echo "rotate: new key line missing; abort" >&2; exit 2; }
chmod --reference="$ENV" "$TMP" || { echo "rotate: chmod failed" >&2; exit 2; }
chown --reference="$ENV" "$TMP" || { echo "rotate: chown failed" >&2; exit 2; }
mv "$TMP" "$ENV" || { echo "rotate: activate failed" >&2; exit 2; }
SUCCESS=1
trap - EXIT INT TERM
echo "rotate: only-key-changed: $KEY"

Invoke it with the new token on stdin (never in argv, never echoed), then restart — the restart is the only step the deploy user performs with sudo:

Terminal
ENV=/etc/arxo/mcp-8722.env
printf '%s\n' "$VAULT_ROTATED_TOKEN" | sudo /usr/local/sbin/arxo-rotate-token "$ENV" LAW_MCP_TOKEN \
&& sudo /usr/bin/systemctl restart law-mcp-private@8722
# expect: rotate: only-key-changed: LAW_MCP_TOKEN, then a restart.
# Any other outcome — including a staging failure — leaves the live
# file untouched and removes the staged old token.
# Any other helper outcome leaves the live file untouched AND skips
# the restart — never restart after a failed rotation.

Verify the rotation itself, not just liveness — a green healthcheck says nothing about which token is enforced. Both checks pipe the Authorization header into curl -K - (stdin config), so no secret appears in argv, in output, or in any intermediate file; the old-token value is the pre-rotation secret the helper staged before activation, not an arbitrary string (the vault’s previous version is the equivalent source). pipefail plus the non-empty guard make a missing token fail the probe loudly instead of earning a false 401; the explicit || exit 1 branches make a wrong HTTP status fail the whole procedure (a bare && echo would skip the message and carry on to cleanup). The %s substitution is safe against config injection exactly because the rotated token alphabet excludes quotes and backslashes (see the supported shape above):

Terminal
HDR='Content-Type: application/json' ACC='Accept: application/json, text/event-stream'
BODY='{"jsonrpc":"2.0","id":1,"method":"ping"}'
set -o pipefail
mkheader() { IFS= read -r t && [ -n "$t" ] && printf 'header = "Authorization: Bearer %s"\n' "$t"; }
# new token works — any other status is a failed rotation, not a
# skipped message: pipefail catches transport errors, the explicit
# branch below catches wrong codes.
CODE=$(printf '%s\n' "$VAULT_ROTATED_TOKEN" | mkheader \
| curl -s -o /dev/null -w '%{http_code}' -K - -H "$HDR" -H "$ACC" -d "$BODY" \
http://127.0.0.1:8722/mcp) || { echo "new-token probe failed" >&2; exit 1; }
[ "$CODE" = 200 ] || { echo "new token was not accepted (HTTP $CODE)" >&2; exit 1; }
echo NEW-TOKEN-OK
# old token is dead
CODE=$(sudo cat /run/arxo-rotated-out-mcp-8722.env | mkheader \
| curl -s -o /dev/null -w '%{http_code}' -K - -H "$HDR" -H "$ACC" -d "$BODY" \
http://127.0.0.1:8722/mcp) || { echo "old-token probe failed" >&2; exit 1; }
[ "$CODE" = 401 ] || { echo "old token still accepted (HTTP $CODE)" >&2; exit 1; }
echo OLD-TOKEN-DEAD
# Cleanup runs only after both mandatory checks passed: a failed
# rotation keeps the staged old token for diagnosis and retry.
sudo rm -f /run/arxo-rotated-out-mcp-8722.env
# expect: NEW-TOKEN-OK, then OLD-TOKEN-DEAD

law serve --token-file rotates the same way, except the file holds a single secret rather than KEY=value lines: stage the new value to a temp file, chmod/chown --reference it to the live file, mv it over, then restart the process. There is no overlap/grace period for two live tokens — clients must switch at restart (current limit).

  • One token per process; no expiry, no scopes, no per-tool keys.
  • No brute-force lockout in the servers; rate limiting lives at the edge (HTTP, TLS and reverse proxies, 3r/s reference-setting).
  • The token travels as a bearer string: without TLS in front it is readable on the wire. Bearer-behind-TLS is the verified shape.
  • Rotation is restart-based with a single live token (above).

No OAuth, OIDC, SAML, LDAP, mTLS/peer-certificate, or API-gateway claim check exists in either server (verified absence: the only authorization read in both codebases is the Bearer comparison). Any “log in with SSO, then call the evaluator” design is therefore an operator-policy-example, not a supported integration, and must obey two hard constraints:

  1. The backend consumes only Authorization: Bearer <token> (plus Origin, X-Forwarded-For, X-Request-Id/traceparent for allow-listing, logging, and tracing). A proxy-injected identity header (X-Remote-User, JWT claim header) is ignored by the backend — it must be translated to the Bearer token at the proxy, or it enforces nothing.
  2. Identity-at-proxy never becomes case rights: once the proxy attaches the token, the backend still authorizes the whole instance, not the user.
SymptomCauseFix
401 on every call incl. pingtoken configured, header missing/wrongsend the exact Authorization: Bearer value
Startup refusal naming LAW_MCP_TOKEN/LAW_MCP_PUBLIC=1non-loopback bind, no tokenbind loopback, set a token, or deliberate --public
--token and --token-file cannot be combinedboth passed to law servekeep one
--token-file ...: empty tokenblank secret filewrite the token without trailing newline surprises (printf '%s')
Token visible in ps/shell historypassed via --token / inline env in a shared shellmove to EnvironmentFile / --token-file
Old token still works after file editprocess not restartedrestart; settings are startup-only
  • “Secure” in this article means only: requests without the token are refused with 401, and an unauthenticated public bind requires an explicit flag. It does not mean encryption (TLS is the edge’s job), user management, audit of who knew the token, or protection of a leaked token.
  • Per-user, per-case, and per-predicate access control are not supported. Do not present tenant separation on one token as isolation: the supported isolation unit is the process (own port, token, profile, OS user).
  • External IdP integration is not supported; proxy-side SSO wrapping is an unverified operator design (constraints above).

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.