# JWT and scopes reference

> Claims (`sub`, `aud`, `scope`, `exp`, `jti`), ES256 vs HS256, scope grammar (`tool:`, `skill:`, `help`, `*`), discovery filtering, and validation failure modes.

- Repository: Parcha-ai/ati
- GitHub: https://github.com/Parcha-ai/ati
- Human docs: https://grok-wiki.com/public/docs/parcha-ai-ati-a9d4398f11fa
- Complete Markdown: https://grok-wiki.com/public/docs/parcha-ai-ati-a9d4398f11fa/llms-full.txt

## Source Files

- `src/core/jwt.rs`
- `src/core/scope.rs`
- `docs/SECURITY.md`
- `docs/JWT_STANDARDS_2026.md`
- `src/cli/token.rs`
- `ati-client/python/src/ati/scope.py`

---

---
title: "JWT and scopes reference"
description: "Claims (`sub`, `aud`, `scope`, `exp`, `jti`), ES256 vs HS256, scope grammar (`tool:`, `skill:`, `help`, `*`), discovery filtering, and validation failure modes."
---

ATI carries authorization in a single JWT: the orchestrator signs it, sandboxes pass it as `ATI_SESSION_TOKEN`, and the proxy (or local CLI when JWT keys are configured) validates signature, audience, and expiry before applying the space-delimited `scope` claim to tool calls, discovery endpoints, and skill visibility.

## Runtime model

```mermaid
sequenceDiagram
    participant Orch as Orchestrator
    participant ATI as ati CLI / proxy
    participant Up as Upstream API

    Orch->>Orch: Sign TokenClaims (ES256 or HS256)
    Orch->>ATI: ATI_SESSION_TOKEN (+ optional _FILE)
    ATI->>ATI: jwt::validate (sig, aud, exp, iss?)
    ATI->>ATI: ScopeConfig::from_jwt
    alt /call or ati run
        ATI->>ATI: check_access(tool_name, tool.scope)
        ATI->>Up: Execute with injected credentials
    else Discovery (/tools, /skills, assist)
        ATI->>ATI: filter_tools_by_scope / visible_skills
    end
```

| Mode | JWT required? | Scope behavior |
|------|---------------|----------------|
| Proxy with `ATI_JWT_PUBLIC_KEY` or `ATI_JWT_SECRET` | Yes — `Authorization: Bearer` on named routes | Strict; missing/invalid token → HTTP 401 |
| Proxy without JWT keys | No | `ScopeConfig::unrestricted()` |
| Local CLI with JWT env configured | Yes — `ATI_SESSION_TOKEN` | Validates token; enforces per-tool `scope` on `ati run` |
| Local CLI without JWT env | No | Unrestricted dev mode |

<Note>
`Ati-Key` authentication (database virtual keys, `db` feature) synthesizes `TokenClaims` from stored scopes and bypasses JWT validation when that header is present. See [Configure JWT and keys](/configure-jwt-and-keys).
</Note>

## Standard claims

`TokenClaims` in `core/jwt.rs` follows RFC 9068-style access-token shape: space-delimited `scope`, required `sub`/`aud`/`exp`/`iat` for validation paths, and an optional `ati` namespace for ATI-specific metadata.

| Claim | Required at issue | Validated on `jwt::validate` | Role |
|-------|-------------------|------------------------------|------|
| `sub` | Yes | Yes (spec claim) | Agent or sandbox identity; copied into `ScopeConfig.sub` |
| `aud` | Yes (default `ati-proxy`) | Yes — must match **any** entry in `JwtConfig.accepted_audiences` | Prevents token substitution across services |
| `exp` | Yes (`iat + ttl`) | Yes | Scope expiry; `ScopeConfig` also re-checks `exp` on access |
| `iat` | Yes | Decoded | Issued-at timestamp (Unix seconds) |
| `scope` | Yes (may be empty string) | Not structurally validated | Space-delimited allowlist; parsed via `TokenClaims::scopes()` |
| `iss` | Optional | Yes when `ATI_JWT_ISSUER` / `required_issuer` set | Issuer allowlist |
| `jti` | Auto-generated on `ati token issue` | Stored, not replay-checked yet | Unique token id for future revocation |
| `job_id` | Optional | No extra check | Orchestrator job correlation |
| `sandbox_id` | Optional | No extra check | Sandbox correlation |
| `ati` | Default namespace on issue | Preserved round-trip | See [ATI namespace](#ati-namespace) |

### ATI namespace

```json
{
  "ati": {
    "v": 1,
    "rate": { "tool:github:*": "10/hour" },
    "customer_id": "cust_alpha"
  }
}
```

| Field | Purpose |
|-------|---------|
| `v` | Claims schema version (currently `1`) |
| `rate` | Per-pattern rate limits; parsed into `ScopeConfig.rate_config` and enforced on `/call` and local `ati run` |
| `customer_id` | Tenant id for proxy credential cascade (customer row → shared row); older tokens without the field deserialize as `None` |

## Scope claim grammar

The `scope` claim is a **single string** of space-separated tokens (RFC 9068 §2.2.3), not a JSON array.

| Pattern | Example | Matches |
|---------|---------|---------|
| Global wildcard | `*` | Any tool scope, `help`, and skill checks via `matches_wildcard` |
| Exact tool | `tool:web_search` | Tool whose manifest `scope` equals `tool:web_search` |
| Provider wildcard | `tool:github:*` | Any tool scope starting with `tool:github:` |
| Skill prefix | `skill:research-*` | Skill name prefix after `skill:` |
| Explicit skill | `skill:my-skill` | Remote/local skill when name is in catalog/registry |
| Help | `help` | Sets `ScopeConfig::help_enabled()` (status reporting; include in tokens that need assist) |
| Legacy underscore | `tool:github_search_repos` | Canonical `tool:github:search_repos` via alias normalization |

### Tool scope strings on manifests

Each public tool gets a `scope` field used at enforcement time:

- Hand-written manifests: set `[[tools]].scope` explicitly, or rely on auto-assignment.
- Auto-assignment: if `scope` is unset and the provider is not `internal`, load time sets `tool:{tool.name}` (full registered name, including `provider:tool` for MCP/OpenAPI).
- OpenAPI import: default `tool:{prefixed_operation_name}` unless overridden.

Authorization compares the JWT scope list against the tool’s `scope` string using `ScopeConfig::is_allowed`, not the CLI tool name alone.

### Wildcard matching rules

`matches_wildcard(name, pattern)` in `core/scope.rs`:

1. `pattern == "*"` → allow.
2. Exact string equality → allow.
3. `pattern` ends with `*` → allow if `name.starts_with(prefix)` where `prefix` is everything before `*`.

There is no infix `*`; `tool:github:*` does not match `tool:linear:list_issues`.

### Building scope strings (Python client)

```python
from ati import build_scope_string

build_scope_string(
    tools=["web_search", "github:*"],
    skills=["research-*"],
    extra=["help"],
)
# → "tool:web_search tool:github:* skill:research-* help"
```

Rust equivalent at issue time:

```bash
ati token issue \
  --sub agent-7 \
  --scope "tool:web_search tool:github:* skill:research-* help" \
  --ttl 1800
```

## ES256 vs HS256

| Aspect | ES256 (recommended) | HS256 |
|--------|---------------------|-------|
| Key material | `ATI_JWT_PUBLIC_KEY` + optional `ATI_JWT_PRIVATE_KEY` PEM | `ATI_JWT_SECRET` (hex-encoded shared secret) |
| Who can issue | Holder of private key only | Any party that knows the secret |
| Who can validate | Anyone with public key / JWKS | Anyone with the same secret |
| JWKS endpoint | Yes — `GET /.well-known/jwks.json` when public PEM configured | No — `public_key_pem` is `None` |
| Default leeway | 60 seconds | 60 seconds |
| `ati token keygen` | `--algorithm ES256` (default) | `--algorithm HS256` |

`jwt::config_from_env()` resolution order:

1. `ATI_JWT_PUBLIC_KEY` → ES256 `JwtConfig` (private key optional; validation-only proxies omit it).
2. Else `ATI_JWT_SECRET` → HS256 `config_from_secret`.
3. Else `None` → JWT disabled (dev/unrestricted paths).

<Warning>
HS256 is appropriate for single-machine or tightly coupled issuer/validator pairs. For multi-tenant proxy deployments, prefer ES256 so sandboxes and validators never hold signing material.
</Warning>

### Audience allowlist

`aud` on the token is a single string. The validator accepts it if it equals **any** value in `accepted_audiences`:

| Env var | Behavior |
|---------|----------|
| `ATI_JWT_ACCEPTED_AUDIENCES` | CSV allowlist (first non-empty wins), e.g. `ati-proxy,parcha-tools` |
| `ATI_JWT_AUDIENCE` | Back-compat single audience (wrapped in one-element vec) |
| (unset) | Default `["ati-proxy"]` |

`validate()` hard-errors if `accepted_audiences` is empty (prevents jsonwebtoken’s silent “accept any aud” behavior).

## Session token delivery

Sandboxes receive the raw JWT via `ATI_SESSION_TOKEN`. Resolution order (`core/token.rs`):

1. Non-empty `ATI_SESSION_TOKEN` env var.
2. `ATI_SESSION_TOKEN_FILE` path, else `/run/ati/session_token`.
3. Per-provider env vars (e.g. `PARCHA_TOOLS_SESSION_TOKEN`) use the same pattern with slugified default paths under `/run/ati/`.

Each `ati` invocation re-reads the file (no in-process cache) so supervisors can rotate tokens without restarting agents.

## Scope enforcement surfaces

After JWT validation, `ScopeConfig::from_jwt` drives authorization.

### Tool execution

| Surface | Denied signal |
|---------|---------------|
| Proxy `POST /call` | HTTP 403, `"Access denied: '{tool}' is not in your scopes"` |
| Proxy, JWT on but no Bearer | HTTP 403, `"Authentication required — no JWT provided"` |
| Local `ati run` | `ScopeError::AccessDenied` / `Expired` printed to stderr |
| Expired scopes | Denied even if signature still parses within leeway window — `is_expired()` uses wall clock vs `exp` |

Tools with **empty** `scope` are always allowed once authenticated.

### Discovery filtering

Scope-filtered listing prevents enumeration of tools and skills outside the token:

| Endpoint / command | Filter mechanism |
|--------------------|------------------|
| `GET /tools`, `ati tool list` | `filter_tools_by_scope` on public tools |
| MCP `tools/list` via `POST /mcp` | Same visible subset |
| `GET /skills`, `ati skill list` | `visible_skills` / `visible_skill_names` |
| `POST /help`, `ati assist` | LLM context built only from `visible_tools_for_scopes` + resolved skills |
| `GET /skillati/*` | `visible_skill_names_with_remote` (explicit `skill:X` + tool/provider/category cascade) |
| `POST /skills/resolve` | Transitive resolution within visible set |

Wildcard JWT scope `*` skips tool filtering and exposes the full public catalog (skills still follow resolver rules unless `*` short-circuits remote catalog).

### Skill resolution cascade

Beyond explicit `skill:name` scopes, skills become visible when their bindings intersect allowed tools:

1. `skill:X` in JWT → skill X if present.
2. `tool:Y` (including wildcards) → skills listing tool Y.
3. Tool’s provider → skills bound to that provider.
4. Provider category → skills bound to that category.
5. `depends_on` → transitive loads during `resolve_skills` (for assist/resolve paths).

A token with only `help` does not grant skill visibility by itself (integration tests treat `help` alone as skill-denied).

### Help scope

`ScopeConfig::help_enabled()` is true when scopes contain `help` or `*`. `ati auth status` reports this flag. Operational docs recommend adding `help` to tokens that use `ati assist` or `POST /help`; discovery still requires tool/skill scopes to populate meaningful context.

## JWT validation failure modes

### Cryptographic / structural (`jwt::validate`)

| Failure | Typical cause | Proxy HTTP | Local CLI |
|---------|---------------|------------|-------------|
| Invalid signature | Wrong secret/key, tampered payload | 401 Unauthorized | `Invalid ATI_SESSION_TOKEN: ...` |
| Expired `exp` | TTL elapsed beyond leeway (60s) | 401 | Same |
| Wrong `aud` | Token minted for different service | 401 | Same |
| Wrong `iss` | `ATI_JWT_ISSUER` mismatch | 401 | Same |
| Malformed JWT | Not three base64url segments | 401 | Same |
| Empty audience config | Misconfigured `accepted_audiences` | 401 (`InvalidKey` message) | Same |

`ati token inspect` decodes payload **without** signature verification (debug only).

`ati token validate` mirrors proxy rules: uses `ATI_JWT_ACCEPTED_AUDIENCES` / `ATI_JWT_AUDIENCE` for audience checks.

### Scope errors (post-validation)

| Error | When | User-visible message |
|-------|------|----------------------|
| `ScopeError::AccessDenied(tool_name)` | Tool scope not matched | `Access denied: '{tool_name}' is not in your scopes` |
| `ScopeError::Expired(ts)` | `now > expires_at` from JWT `exp` | `Scopes have expired (expired at {ts})` |
| Rate limit | `ati.rate` pattern match | HTTP 429 on proxy; error string locally |

### Dev vs production misconfiguration

| Symptom | Likely cause |
|---------|--------------|
| All tools visible locally | No `ATI_JWT_PUBLIC_KEY` / `ATI_JWT_SECRET` — unrestricted dev mode |
| `ATI_SESSION_TOKEN is required because JWT validation is configured locally` | Keys set but token missing |
| Proxy returns 401 on every route | JWT configured but missing/invalid Bearer |
| Proxy returns 403 on `/call` with valid JWT | Scope string lacks required `tool:...` pattern |
| `ati auth status` shows `Verified: NO` | Inspect works; full validate needs matching public key/secret in env |

## CLI commands

<Steps>
<Step title="Generate keys">
<CodeGroup>
```bash title="ES256 (production)"
ati token keygen --algorithm ES256
# Set ATI_JWT_PRIVATE_KEY and ATI_JWT_PUBLIC_KEY to PEM paths
```

```bash title="HS256 (simple)"
ati token keygen --algorithm HS256
# Set ATI_JWT_SECRET to printed hex
```
</CodeGroup>
</Step>
<Step title="Issue a scoped token">
```bash
ati token issue \
  --sub sandbox-abc \
  --scope "tool:finnhub:* tool:github:* help" \
  --ttl 1800 \
  --aud ati-proxy \
  --rate "tool:github:*=10/hour"
export ATI_SESSION_TOKEN="<printed token>"
```
Default TTL is **1800** seconds (30 minutes).
</Step>
<Step title="Verify before deploy">
```bash
ati token inspect "$ATI_SESSION_TOKEN"
ati token validate "$ATI_SESSION_TOKEN"
ati auth status
```
</Step>
</Steps>

| Command | Purpose |
|---------|---------|
| `ati token keygen` | Create ES256 PEM pair or HS256 hex secret |
| `ati token issue` | Sign `TokenClaims`; auto `jti` UUID; default `ati` namespace |
| `ati token inspect` | JSON dump of claims (no signature check) |
| `ati token validate` | Full verify; exits 1 on failure |
| `ati auth status` | Human/json summary: scopes, expiry, `help_enabled`, signature verified |

## Environment variables (JWT)

| Variable | Role |
|----------|------|
| `ATI_JWT_PUBLIC_KEY` | ES256 public PEM path (validation + JWKS) |
| `ATI_JWT_PRIVATE_KEY` | ES256 private PEM path (issuance) |
| `ATI_JWT_SECRET` | HS256 hex secret (issue + validate) |
| `ATI_JWT_ISSUER` | Required issuer when set |
| `ATI_JWT_AUDIENCE` | Single accepted audience (default `ati-proxy`) |
| `ATI_JWT_ACCEPTED_AUDIENCES` | CSV multi-audience allowlist |
| `ATI_SESSION_TOKEN` | Bearer token for CLI and proxy client |
| `ATI_SESSION_TOKEN_FILE` | Hot-rotation file path override |

## Minimal token examples

<RequestExample>
```json
{
  "sub": "agent-7",
  "aud": "ati-proxy",
  "iat": 1748736000,
  "exp": 1748737800,
  "jti": "dbe39bf3-a3ba-4238-a513-f51d6e1691c4",
  "scope": "tool:web_search tool:github:* skill:research-* help",
  "iss": "ati-orchestrator",
  "ati": { "v": 1, "customer_id": "cust_alpha" }
}
```
</RequestExample>

<Tip>
Use `ati token issue` for production tokens so `jti`, `iat`, and `exp` stay consistent with proxy validation defaults.
</Tip>

## Related pages

<CardGroup>
<Card title="Configure JWT and keys" href="/configure-jwt-and-keys">
Key generation, `ati init --proxy`, orchestrator issuance patterns, and session token files.
</Card>
<Card title="Scopes and tool discovery" href="/scopes-and-tool-discovery">
Discovery tiers (`ati tool search`, `ati tool info`, `ati assist`) on top of scope-filtered catalogs.
</Card>
<Card title="Execution modes" href="/execution-modes">
Local keyring vs proxy mode and where credentials vs JWT scopes live.
</Card>
<Card title="Proxy API reference" href="/proxy-api-reference">
`/call`, `/help`, `/tools`, `/skillati/*`, auth headers, and response shapes.
</Card>
<Card title="Environment variables" href="/environment-variables">
Full runtime env contract including JWT and session token variables.
</Card>
<Card title="Security and production" href="/security-and-production">
Threat model, scope enumeration prevention, and proxy hardening checklist.
</Card>
</CardGroup>
