Policy Engine
JWT-based access control for multi-user and multi-agent MCP HTTP deployments.
The policy engine controls which MCP callers can invoke which tools. It only comes into play when Earl's HTTP transport is running with JWT authentication — if you're using stdio, or you passed --allow-unauthenticated, none of this applies.
JWT Authentication
Configure JWT validation in ~/.config/earl/config.toml.
OIDC Discovery (recommended)
If your identity provider publishes a standard discovery document, use this. Earl fetches the JWKS endpoint automatically.
[auth.jwt]
audience = "https://api.yourcompany.com"
oidc_discovery_url = "https://accounts.yourcompany.com/.well-known/openid-configuration"Manual JWKS
Use this when there's no discovery endpoint.
[auth.jwt]
audience = "https://api.yourcompany.com"
issuer = "https://accounts.yourcompany.com"
jwks_uri = "https://accounts.yourcompany.com/.well-known/jwks.json"
algorithms = ["RS256"]
clock_skew_seconds = 30
jwks_cache_max_age_seconds = 900Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
audience | string | Yes | — | Expected aud claim |
oidc_discovery_url | string | If issuer/jwks_uri not set | — | Discovery URL; resolves issuer and JWKS URI automatically |
issuer | string | If oidc_discovery_url not set | — | Expected iss claim |
jwks_uri | string | If oidc_discovery_url not set | — | JWKS endpoint |
algorithms | string[] | No | ["RS256"] | Allowed signing algorithms |
clock_skew_seconds | integer | No | 30 | Clock skew tolerance (max 300) |
jwks_cache_max_age_seconds | integer | No | 900 | JWKS cache TTL in seconds |
Supported algorithms: RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384, PS512, EdDSA.
Incoming tokens must carry exp, sub, iss, and aud claims. The sub claim is what policy rules match against.
Policy Rules
Add [[policy]] entries to ~/.config/earl/config.toml. Each rule targets a set of subjects, a set of tools, an optional mode filter, and an effect.
# All authenticated users can read from GitHub
[[policy]]
subjects = ["user:*"]
tools = ["github.*"]
modes = ["read"]
effect = "allow"
# Admins can do anything with GitHub tools
[[policy]]
subjects = ["group:admins"]
tools = ["github.*"]
effect = "allow"
# Nobody can invoke write-mode destructive commands
[[policy]]
subjects = ["*"]
tools = ["github.delete_repo", "github.merge_pull"]
modes = ["write"]
effect = "deny"Fields
| Field | Type | Required | Description |
|---|---|---|---|
subjects | string[] | Yes | Caller identity patterns |
tools | string[] | Yes | Tool name patterns |
modes | string[] | No | "read", "write", or both. Omit to match any mode. |
effect | string | Yes | "allow" or "deny" |
Subject patterns
| Pattern | Matches |
|---|---|
user:alice | JWT sub equals alice |
user:* | Any sub value |
group:admins | Callers with admins in their groups claim |
* | Any authenticated caller |
Tool patterns
* matches within a single dot-separated segment.
| Pattern | Matches | Does not match |
|---|---|---|
github.* | github.create_issue, github.search_repos | slack.send_message |
*.delete_* | github.delete_repo, stripe.delete_customer | github.admin.delete |
* | Any tool | — |
How evaluation works
Earl uses deny-overrides: collect every rule that matches the subject, tool, and mode. If any of them is a deny, the call is rejected. If there's at least one allow and no deny, the call goes through. If nothing matches, the call is rejected — there's no implicit allow.
One deny beats any number of allows.
Multi-team example
# Team Alpha can read from GitHub and Stripe
[[policy]]
subjects = ["group:alpha"]
tools = ["github.*", "stripe.*"]
modes = ["read"]
effect = "allow"
# Team Beta has full GitHub access
[[policy]]
subjects = ["group:beta"]
tools = ["github.*"]
effect = "allow"
# Nobody runs write-mode system commands over MCP
[[policy]]
subjects = ["*"]
tools = ["system.*"]
modes = ["write"]
effect = "deny"Starting the HTTP server
earl mcp http --listen 0.0.0.0:8977Earl won't start without either [auth.jwt] configured or --allow-unauthenticated passed. You can't use both.
For local dev:
earl mcp http --listen 127.0.0.1:8977 --allow-unauthenticated--allow-unauthenticated skips authentication and the policy engine. Don't use it anywhere shared.
See also
- MCP Integration — transport options and write-mode safety
- Configuration — full
config.tomlreference - Hardening — network egress and production checklist