Skip to main content
The TokenValidator runs in-process inside the API service for sub-10ms token validation. No network call to the auth service is required on the hot path.

Pipeline Steps

Raw token: qt_agent_eyJhbGciOiJFUzI1NiIs...


┌─────────────────────────────┐
│ 1. Prefix Detection         │  ~0.01ms
│    Match qt_* prefix        │
│    → TokenType.AGENT        │
└──────────────┬──────────────┘

┌─────────────────────────────┐
│ 2. Customer ID Peek         │  ~0.01ms
│    Decode JWT without       │
│    verification, read sub   │
└──────────────┬──────────────┘

┌─────────────────────────────┐
│ 3. JWT Decode + ES256       │  ~0.5ms
│    Verify signature with    │
│    cached public key        │
│    Check expiry, claims     │
└──────────────┬──────────────┘

┌─────────────────────────────┐
│ 4. Bloom Filter Check       │  ~1-2ms
│    Redis GETBIT × 7 hashes  │
│    → revoked or not         │
└──────────────┬──────────────┘

┌─────────────────────────────┐
│ 5. Chain Verification       │  ~0.01ms
│    Verify required claims   │
│    per token type            │
└──────────────┬──────────────┘

        ValidatedToken
Total latency: ~2-3ms

Step Details

Step 1: Prefix Detection

Token type is determined by a simple string prefix match — no JWT decoding required.
TOKEN_PREFIXES = {
    "qt_app_":      "app",
    "qt_bearer_":   "bearer",
    "qt_agent_":    "agent",
    "qt_subagent_": "subagent",
    "qt_session_":  "session",
    "qt_override_": "override",
}
If no prefix matches, TokenInvalidError is raised immediately.

Step 2: Customer ID Peek

The JWT payload is decoded without signature verification to extract the sub claim (customer_id). This is needed to look up the correct public key for Step 3. This is safe because the unverified payload is only used as a cache key — the actual verification happens in Step 3.

Step 3: JWT Decode + ES256 Verify

The JWT (everything after the prefix) is decoded and verified using the customer’s cached ES256 public key. Public key caching: Keys are loaded at startup and refreshed every 5 minutes from the auth service’s GET /keys/public/{customer_id} endpoint. Stored in-memory on the TokenValidator instance. Errors at this stage:
  • TokenExpiredError — JWT has expired
  • TokenInvalidError — signature mismatch, malformed JWT, missing claims
  • TokenInvalidErrorsub claim doesn’t match peeked customer_id
  • TokenInvalidErrortyp claim doesn’t match detected prefix

Step 4: Bloom Filter Revocation Check

A Redis bitmap bloom filter provides O(1) revocation checking. If any of the 7 bit positions are unset, the token is definitely not revoked.
JTI → SHA-256 → h1 (first 8 bytes), h2 (next 8 bytes)
positions = [(h1 + i * h2) % 1_000_000  for i in range(7)]
→ Redis GETBIT on each position
→ ALL set = possibly revoked → TokenRevokedError
→ ANY unset = definitely not revoked → pass
False positive rate: ~0.8% at 100k revoked tokens with 1M bits and 7 hashes. A false positive causes the token to be rejected — the customer should re-issue it. This is an acceptable tradeoff for O(1) latency. Rebuild: If the bloom filter is lost (Redis restart), it can be rebuilt from auth.revocation_log via POST /bloom/rebuild.

Step 5: Chain Verification

Each token type has required claims that must be present for the derivation chain to be valid:
TypeRequiredValidated
App(root, no chain)
Bearerparent_jti, envParent was an app token
Agentparent_jti, agent_id, rbacParent was a bearer token
Subagentparent_jti, agent_id, rbac, depthParent was an agent token
Sessionparent_jti, session_idParent was agent or subagent
Overrideevent_idBound to specific event

RBAC Enforcement (Post-Validation)

RBAC is enforced after validation, as a FastAPI dependency on protected routes. It is not part of the TokenValidator itself.
ValidatedToken.claims.rbac  →  check_rbac(policy, action, resource)
This separation keeps the validator focused on authentication while RBAC handles authorization.

Integration: Auth Middleware

The AuthMiddleware in src/api/middleware/auth.py orchestrates the dual-mode flow:
Request arrives

    ├── Has "Authorization: Bearer qt_*" header?
    │   └── YES → TokenValidator.validate(token)
    │            → Set request.state.token = ValidatedToken
    │            → Also validate X-Quint-Session if present

    └── Has "X-API-Key" header?
        └── YES → Legacy SHA-256 hash lookup
                 → Set request.state.customer (no token)
                 → Add X-Quint-Deprecation response header

Error Handling

ErrorHTTP StatusWhen
TokenExpiredError401JWT exp is in the past
TokenRevokedError401JTI found in bloom filter
TokenInvalidError401Bad signature, missing claims, wrong type
RBACDeniedError403Action not permitted by RBAC policy
SignatureInvalidError401Action signature verification failed
SessionExhaustedError429Session event counter exceeded max_events

Configuration

Environment VariableDefaultDescription
REDIS_URLredis://localhost:6379/0Redis for bloom filter + session counters
AUTH_SERVICE_URLhttp://localhost:8001Auth service for key refresh
BLOOM_FILTER_SIZE1000000Bits in bloom filter bitmap
BLOOM_FILTER_HASH_COUNT7Number of hash functions
REDIS_PUBLIC_KEY_CACHE_TTL300Public key cache TTL (seconds)