SECRETS NEVER ENTER THE CONTEXT WINDOW.
When a Claude agent calls a tool, every API key, OAuth token, and database URL stays inside the MCP subprocess. The model sees the tool name and the result, never the credential. No secrets in prompts, completions, logs, or stored conversation history.
Server-Side Credential Injection
When a Claude agent calls a tool, the credential it needs to authenticate the downstream API never crosses the model boundary. spawn_server() in the MCP process spawner receives the resolved Secrets struct from SecretsBootstrap::get(), then sets ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, and GITHUB_TOKEN directly on the child Command environment before spawn(). The credential lives in the subprocess address space, not in any prompt the model is built from.
Custom credentials follow the same path. The Secrets struct flattens an arbitrary HashMap<String, String> via #[serde(flatten)], and the spawner emits two environment artifacts in one pass. SYSTEMPROMPT_CUSTOM_SECRETS is set to a comma-separated list of uppercased keys so the child process can discover its allowlist at startup, and custom_env_vars() injects each value under both its original and uppercased name. A custom variable becomes available to the MCP service without any code change in the host binary.
Because the secret is bound to the subprocess environment and not to a request body, it cannot appear in a prompt, a completion, a tool argument, a tool result, or any row of stored conversation history. McpToolExecutor::execute records the tool name, server name, input arguments, status, and a generated McpExecutionId through ToolUsageRepository::start_execution. The audit row is the lineage; the credential is not in it.
- Subprocess Environment, Not Prompt — spawn_server() sets ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, and GITHUB_TOKEN on the child Command before spawn(). The model receives the tool result, never the key used to authenticate the call.
- Custom Secrets via SYSTEMPROMPT_CUSTOM_SECRETS — The spawner publishes a comma-separated allowlist of uppercased custom keys via SYSTEMPROMPT_CUSTOM_SECRETS, then injects each value under both original and uppercased names through Secrets::custom_env_vars(). New credentials require zero host changes.
- Audit Without Disclosure — McpToolExecutor::execute calls ToolUsageRepository::start_execution with tool_name, server_name, input, started_at, and a McpExecutionId. The lineage is written; the credential is not.
- spawner.rs (lines 26-120) spawn_server() injects provider keys and custom secrets into the MCP child Command
- secrets.rs (lines 86-140) Secrets::get() with case-insensitive lookup and custom_env_vars() emitting both casings
- tool.rs (lines 51-180) McpToolExecutor::execute records exec_id, tool_name, server_name, input via ToolUsageRepository
- tool.rs (lines 17-49) McpToolHandler trait defining tool_name, input_schema, and handle()
- plugin.rs PluginVariableDef with secret flag for scoped credentials
JWT Session Security
A stolen session cookie should not become a long-lived API key. Every systemprompt.io session is an HS256 JWT signed with the jsonwebtoken crate, and the signing key is checked at startup. Secrets::validate() rejects any jwt_secret shorter than the JWT_SECRET_MIN_LENGTH constant of 32 characters, so a misconfigured profile cannot boot with a weak secret.
SessionGenerator::generate() embeds the user's scope, user_type, roles, session_id, and rate_limit_tier into typed JwtClaims alongside the standard sub, exp, iss, and aud fields. Every token carries a unique jti generated by uuid::Uuid::new_v4(), which gives audit and revocation tooling a stable handle per session.
AuthValidationService runs on every request and exposes three explicit modes. Required calls validate_and_fail_fast(): extract bearer token, decode HS256 claims, validate issuer and audience, compare exp against Utc::now(), return 401 on any failure. Optional falls back to an anonymous RequestContext. Disabled is reserved for tests. The JwtClaims methods has_permission(), is_admin(), is_anonymous(), has_audience(), and has_role() let downstream handlers make typed authorization decisions without re-parsing the token.
- HS256 JWT Signing — SessionGenerator signs tokens with Algorithm::HS256 via the jsonwebtoken crate. 32-character minimum secret enforced by JWT_SECRET_MIN_LENGTH. Unique jti per token via uuid::Uuid::new_v4().
- Three Auth Modes — AuthValidationService supports Required (fail-fast), Optional (anonymous fallback), and Disabled (test) modes. validate_token() decodes claims, validates issuer, audience, and expiry.
- Typed Claims — JwtClaims struct with has_permission(), is_admin(), is_anonymous(), has_audience(), has_role(). scope, user_type, roles, session_id, and rate_limit_tier encoded in every token.
- generator.rs SessionGenerator::generate() builds HS256 JwtClaims with jti, scope, roles, and rate_limit_tier
- validation.rs AuthValidationService Required, Optional, and Disabled modes
- claims.rs JwtClaims with has_permission(), is_admin(), is_anonymous(), has_audience(), has_role()
- secrets.rs (line 9, line 62) JWT_SECRET_MIN_LENGTH = 32 enforced by Secrets::validate()
- jwt/mod.rs JwtService::generate_admin_token() with Permission::Admin and RateLimitTier::Admin
- auth.rs validate_jwt_token() for MCP servers
Scanner Detection at the Edge
Most credential leaks start with a probe. Mass scanners walk the internet looking for .env, .git, and exposed config files, and any one of those that lands in front of an AI workload becomes a path to the secrets store. ScannerDetector::is_scanner_path rejects the probe before any handler runs. The SCANNER_EXTENSIONS table at scanner.rs lines 3-5 lists 12 extensions: php, env, git, sql, bak, old, zip, gz, db, config, cgi, htm. The SCANNER_PATHS table at lines 7-26 lists 18 directory and file patterns including /wp-admin, /phpmyadmin, /xmlrpc, /cgi-bin, /shell.php, /c99.php, /eval-stdin.php, and /manager/html.
ScannerDetector::is_scanner_agent matches the user-agent against a fixed list of scanner tools (masscan, nmap, nikto, sqlmap, acunetix, nessus, openvas, w3af, metasploit, burpsuite, zap, zgrab, censys, shodan, and more), rejects bare Mozilla/5.0 and any user-agent shorter than MIN_USER_AGENT_LENGTH (10 characters), and rejects outdated browsers below Chrome 120 or Firefox 120 via is_outdated_browser. Generic HTTP clients (curl, wget, python-requests, go-http-client, ruby) are rejected when their user-agent length sits below per-client thresholds defined as constants in the same file.
ScannerDetector::is_high_velocity takes a request count and a duration in seconds, normalises to requests per minute, and compares against MAX_REQUESTS_PER_MINUTE (30.0). The composite is_scanner at lines 138-168 chains path, user-agent, and velocity checks in order. Any positive signal returns true, and a missing user-agent returns true on its own. The check runs inline on the request, not as post-hoc log analysis.
- 12 Extensions, 18 Paths — SCANNER_EXTENSIONS lists php, env, git, sql, bak, old, zip, gz, db, config, cgi, htm. SCANNER_PATHS lists 18 patterns including /wp-admin, /phpmyadmin, /xmlrpc, /cgi-bin, /eval-stdin.php, /shell.php, /c99.php. Both tables live in scanner.rs lines 3-26.
- User-Agent Allowlisting — is_scanner_agent rejects 20+ named scanner tools, bare Mozilla/5.0, user-agents under 10 characters, and Chrome or Firefox below version 120. Generic HTTP clients are rejected by per-client length thresholds.
- Velocity Threshold — is_high_velocity normalises request count and duration to requests per minute and rejects anything above MAX_REQUESTS_PER_MINUTE (30.0). The composite is_scanner() blocks the request on the first positive signal, inline.
- scanner.rs (lines 3-26) SCANNER_EXTENSIONS (12 entries) and SCANNER_PATHS (18 entries) constants
- scanner.rs (lines 62-127) is_scanner_agent and is_outdated_browser with named scanner tools and version thresholds
- scanner.rs (lines 129-168) is_high_velocity and composite is_scanner chaining path, agent, and velocity
- rbac.rs enforce_rbac_from_registry() gateway middleware
Token Extraction Chain
A browser sends a session in a cookie. A CLI sends a bearer token in the Authorization header. An MCP proxy in front of the server sends a verified identity in x-mcp-proxy-auth. A single hardcoded extraction strategy breaks one of those callers. TokenExtractor takes an explicit ordered Vec<ExtractionMethod> at construction and walks it in extract(), returning the first hit and falling through to TokenExtractionError::NoTokenFound only when every method has been tried.
Three constructors fix the chain to common deployments. TokenExtractor::standard() orders Authorization, MCP proxy, then Cookie. browser_only() drops the MCP proxy method. api_only() accepts the Authorization header alone, which is the strict mode for machine-to-machine traffic. TokenExtractor::new() takes any ordered list of ExtractionMethod variants for custom deployments, and with_cookie_name() and with_mcp_header_name() override the defaults.
enforce_rbac_from_registry() in the MCP middleware loads the server's deployment config, checks if OAuth is required, attempts proxy-verified auth first, then falls back to bearer token validation with validate_and_extract_claims(). Audience validation, scope validation, and permission checks run in sequence. The result is either an AuthenticatedRequestContext with the validated claims, or an anonymous context for unauthenticated access.
- Configurable Extraction — TokenExtractor supports three ExtractionMethod variants: AuthorizationHeader, McpProxyHeader, Cookie. Pre-built chains: standard() (all three), browser_only() (no MCP), api_only() (header only).
- MCP Proxy Authentication — x-mcp-proxy-auth header enables proxy-verified authentication for MCP servers. The RBAC middleware validates proxy auth first, then falls back to direct bearer token extraction.
- Eight Error Types — TokenExtractionError covers NoTokenFound, MissingAuthorizationHeader, InvalidAuthorizationFormat, MissingMcpProxyHeader, InvalidMcpProxyFormat, MissingCookie, InvalidCookieFormat, TokenNotFoundInCookie. No ambiguous failures.
- token.rs (lines 9-69) ExtractionMethod variants and TokenExtractor::standard, browser_only, api_only constructors
- token.rs (extract) TokenExtractor::extract walks the fallback chain and returns NoTokenFound on exhaustion
- rbac.rs enforce_rbac_from_registry with proxy-first auth and validate_and_extract_claims fallback
- auth.rs validate_jwt_token with issuer and audience checks
- session_manager.rs DatabaseSessionManager for MCP session lifecycle
Per-User Secret Isolation
An admin token and a guest cookie should never resolve to the same set of MCP tools. The Secrets struct stores typed provider keys (jwt_secret, database_url, anthropic, openai, gemini, github) alongside a flattened HashMap<String, String> for arbitrary custom credentials. The SecretsConfig at profile/secrets.rs selects the source (file or env), and SecretsValidationMode exposes three startup behaviours: Strict rejects misconfiguration, Warn logs and continues (the default), and Skip bypasses validation for tests.
The Permission enum at permission.rs lines 10-16 defines six tiers: Admin, User, Anonymous, A2a, Mcp, and Service. Each tier maps to a UserType via as_user_type() at lines 52-61, with the inverse direction at from_user_type(). SessionGenerator embeds the permission set, the role list, and the RateLimitTier directly into the JWT, so authorization decisions on subsequent requests read from claims instead of the database.
At the MCP layer, PluginVariableDef marks variables as secret: true to scope credentials at definition time. McpToolLoader::load_tools_for_servers calls has_server_permission() against the caller's permissions before loading any tools, and servers the caller cannot access are silently dropped from the manifest. McpToolExecutor::execute records the RequestContext on every call, so the audit row links identity to permission to tool invocation in one chain.
- Typed Secret Storage — Secrets struct holds jwt_secret, database_url, anthropic, openai, gemini, github, plus a flattened HashMap for custom credentials. parse() validates JSON; validate() enforces JWT_SECRET_MIN_LENGTH (32 characters).
- Six Permission Tiers — Permission enum: Admin, User, Anonymous, A2a, Mcp, Service. as_user_type() maps each to a UserType. JwtClaims exposes has_permission(), is_admin(), is_anonymous(), has_audience(), and has_role().
- Server-Level Scoping — McpToolLoader::load_tools_for_servers calls has_server_permission() before loading tools. PluginVariableDef marks variables as secret: true. Servers the caller cannot access are silently dropped.
- secrets.rs (lines 11-43) Secrets struct with typed provider fields and #[serde(flatten)] custom HashMap
- profile/secrets.rs SecretsConfig and SecretsValidationMode (Strict, Warn, Skip)
- permission.rs (lines 10-72) Permission enum (Admin, User, Anonymous, A2a, Mcp, Service) with as_user_type() and from_user_type()
- loader.rs McpToolLoader::load_tools_for_servers with has_server_permission() checks
- plugin.rs PluginVariableDef with secret flag
- claims.rs JwtClaims with permission and role methods
Founder-led. Self-service first.
No sales team. No demo theatre. The template is free to evaluate — if it solves your problem, we talk.
Who we are
One founder, one binary, full IP ownership. Every line of Rust, every governance rule, every MCP integration — written in-house. Two years of building AI governance infrastructure from first principles. No venture capital dictating roadmap. No advisory board approving features.
How to engage
Evaluate
Clone the template from GitHub. Run it locally with Docker or compile from source. Full governance pipeline.
Talk
Once you have seen the governance pipeline running, book a meeting to discuss your specific requirements — technical implementation, enterprise licensing, or custom integrations.
Deploy
The binary and extension code run on your infrastructure. Perpetual licence, source-available under BSL-1.1, with support and update agreements tailored to your compliance requirements.