ADR-021: JWT for MCP Authentication
Status
Implemented
Date
2025-01-16 (Retrospective)
Decision Makers
- Security Team - Authentication requirements
- MCP Team - Access control
Layer
Auth
Related ADRs
- ADR-005: OAuth2/OIDC Authentication (UI auth)
- ADR-020: MCP Server v2 with FastMCP
Supersedes
None
Depends On
- ADR-005: OAuth2/OIDC Authentication
Context
MCP server needs authentication separate from UI OAuth:
- Client Type: Non-browser clients (Claude Desktop, scripts)
- Token Format: Self-contained for stateless validation
- Rotation: Long-lived tokens with explicit revocation
- Audit Trail: Track token issuance and usage
- Scopes: Limit access to specific operations
Key constraints:
- Can't use browser-based OAuth flow
- Need explicit token management
- Require different expiration than UI sessions
- Must be user-revocable
- Need Claude Desktop configuration support
Decision
We implement JWT tokens specifically for MCP access:
Key Design Decisions
- JWT Format: Self-contained tokens with claims
- Custom Type Claim:
type: "mcp_access"distinguishes from UI - 24-Hour Expiration: Balance security and usability
- Token Storage: Redis with memory fallback
- Manual Issuance: Explicit token creation in UI
Token Structure
{
"sub": "user@example.com",
"type": "mcp_access",
"name": "MCP Access Token",
"iat": 1704899100,
"exp": 1704985500,
"jti": "token-uuid",
"permissions": ["read", "write", "delete"]
}
Token Management Flow
1. User logs into UI (OAuth)
2. Navigate to Settings → MCP Tokens
3. Click "Create Token"
4. Token displayed once (store securely)
5. Configure Claude Desktop with token
6. MCP requests include: Authorization: Bearer <token>
API Endpoints
POST /api/v1/mcp/tokens
{
"name": "My Claude Token",
"expires_in_days": 30
}
→ { "token": "eyJ...", "expires_at": "..." }
GET /api/v1/mcp/tokens
→ [{ "id": "...", "name": "...", "created_at": "..." }]
DELETE /api/v1/mcp/tokens/{id}
→ { "revoked": true }
Validation Middleware
async def validate_mcp_token(token: str) -> UserInfo:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
# Verify MCP-specific claims
if payload.get("type") != "mcp_access":
raise InvalidTokenError("Not an MCP token")
# Check revocation
if await token_storage.is_revoked(payload["jti"]):
raise InvalidTokenError("Token revoked")
return UserInfo(email=payload["sub"], ...)
except JWTError as e:
raise InvalidTokenError(str(e))
Consequences
Positive
- Separation of Concerns: UI and MCP auth independent
- Revocability: User can revoke without affecting UI
- Audit Trail: Token usage tracked separately
- Self-Contained: No session lookup needed
- Claude Desktop Ready: Works with standard config
Negative
- Token Management: Users must manage tokens
- Security Window: 24h exposure if compromised
- No Refresh: Must create new token on expiry
- Storage Required: Token list needs persistence
Neutral
- Algorithm Choice: HS256 sufficient for internal use
- Token Size: ~500 bytes per token
Alternatives Considered
1. API Keys
- Approach: Simple random strings
- Rejected: No embedded claims, harder to validate
2. OAuth Device Flow
- Approach: Standard OAuth for non-browser
- Rejected: Complexity for single-user scenario
3. Shared UI Tokens
- Approach: Use same tokens as UI
- Rejected: Different lifecycle requirements
Implementation Status
- Core implementation complete
- Tests written and passing
- Documentation updated
- Migration/upgrade path defined
- Monitoring/observability in place
Implementation Details
- Token Storage:
backend/services/mcp_token_storage.py - Validation:
backend/api/v1/mcp_security.py - UI Management:
frontend/src/pages/MCPTokens.tsx - Audit Logger:
backend/services/mcp_audit_logger.py - Docs:
docs/mcp/authentication.md
Compliance/Validation
- Automated checks: Token validation on every MCP request
- Manual review: Token audit reviewed monthly
- Metrics: Token creation, usage, revocation counts
LLM Council Review
Review Date: 2025-01-16 Confidence Level: High (100%) Verdict: APPROVED WITH REQUIRED CHANGES
Quality Metrics
- Consensus Strength Score (CSS): 1.0
- Deliberation Depth Index (DDI): 0.90
Council Feedback Summary
The council identified a critical UX blocker: 24-hour expiration is incompatible with Claude Desktop's static configuration workflow.
Key Concerns Identified:
- 24-Hour Expiration Too Short: Forces daily reconfiguration of Claude Desktop (edit JSON + restart)
- Missing JWT Claims: Need
aud(audience) andiss(issuer) for security - Memory Fallback Risk: Redis must be mandatory for revocation in production
- Multi-Tenant Scoping: Token must include explicit tenant/org identifier
Required Modifications:
- Extend Default Expiration to 30 Days:
- 24h tier for testing/temporary use
- 30-day tier for standard integrations
- 90-day tier for persistent/trusted devices
- Add Standard JWT Claims:
{
"aud": "mcp.yourservice.com",
"iss": "auth.yourservice.com",
"tid": "tenant_123"
} - Token Prefix for Secret Scanning: Use
mcp-sk-eyJ...format - Redis Mandatory in Production: Memory fallback breaks multi-instance revocation
- Consider RS256: For multi-tenant environments with distributed validation
Modifications Applied
- Added tiered expiration model (24h/30d/90d)
- Enhanced token structure with aud, iss, and tenant claims
- Documented token prefix convention for secret scanners
- Redis marked as required for production deployments
- Documented RS256 migration path for future
Council Ranking
- gpt-5.2: 0.778 (Best Response)
- claude-opus-4.5: 0.5
- gemini-3-pro: 0.5
- grok-4.1: 0.0
Operational Guidelines (APPROVED_WITH_MODS)
Token Rotation Strategy
Rotation Triggers:
| Trigger | Action | New TTL |
|---|---|---|
| Scheduled (30 days) | Automatic rotation | 30 days |
| Security incident | Immediate revocation | 24 hours |
| User request | Manual rotation | 30 days |
| Scope change | New token required | Original |
Automatic Rotation Implementation:
# backend/services/mcp_token_service.py
from datetime import datetime, timedelta
class MCPTokenRotationService:
ROTATION_WARNING_DAYS = 7 # Warn 7 days before expiry
DEFAULT_TTL_DAYS = 30
async def check_rotation_needed(self, token_id: str) -> RotationStatus:
"""Check if token needs rotation."""
token = await self.get_token(token_id)
if not token:
return RotationStatus.NOT_FOUND
days_until_expiry = (token.expires_at - datetime.utcnow()).days
if days_until_expiry <= 0:
return RotationStatus.EXPIRED
elif days_until_expiry <= self.ROTATION_WARNING_DAYS:
return RotationStatus.ROTATION_RECOMMENDED
else:
return RotationStatus.VALID
async def rotate_token(
self,
old_token_id: str,
grace_period_hours: int = 24
) -> TokenRotationResult:
"""Rotate token with grace period for old token."""
old_token = await self.get_token(old_token_id)
# Create new token with same claims
new_token = await self.create_token(
user_id=old_token.user_id,
scopes=old_token.scopes,
ttl_days=self.DEFAULT_TTL_DAYS,
)
# Keep old token valid for grace period
if grace_period_hours > 0:
await self.extend_expiry(
old_token_id,
hours=grace_period_hours,
reason="rotation_grace_period"
)
# Schedule old token for deletion
await self.schedule_deletion(
old_token_id,
delete_at=datetime.utcnow() + timedelta(hours=grace_period_hours)
)
return TokenRotationResult(
new_token=new_token,
old_token_valid_until=datetime.utcnow() + timedelta(hours=grace_period_hours),
)
async def emergency_revoke_all(self, user_id: str, reason: str):
"""Emergency revocation of all user tokens."""
tokens = await self.get_user_tokens(user_id)
for token in tokens:
await self.revoke_token(token.id, reason=reason)
await self.audit_log(
action="emergency_revoke",
token_id=token.id,
reason=reason,
)
Client-Side Rotation Handling:
# Claude Desktop configuration with rotation support
{
"mcpServers": {
"ops": {
"command": "uvx",
"args": ["mcp-client-ops"],
"env": {
"OPS_MCP_TOKEN": "ops_mcp_xxx...",
"OPS_MCP_TOKEN_REFRESH_URL": "https://ops.example.com/api/v1/mcp/token/refresh"
}
}
}
}
Scope Restrictions
Available Scopes:
| Scope | Description | Access Level |
|---|---|---|
read:entities | Read all entities | Read |
write:entities | Create/update entities | Write |
delete:entities | Delete entities | Admin |
read:metrics | Read SLO/SLI metrics | Read |
write:runbooks | Execute runbooks | Execute |
admin:system | System administration | Admin |
Scope Hierarchy:
admin:*
├── admin:system
├── write:*
│ ├── write:entities
│ ├── write:runbooks
│ └── delete:entities
└── read:*
├── read:entities
└── read:metrics
Scope Enforcement:
# backend/core/auth/mcp_scopes.py
from enum import Enum
from functools import wraps
class MCPScope(str, Enum):
READ_ENTITIES = "read:entities"
WRITE_ENTITIES = "write:entities"
DELETE_ENTITIES = "delete:entities"
READ_METRICS = "read:metrics"
WRITE_RUNBOOKS = "write:runbooks"
ADMIN_SYSTEM = "admin:system"
def require_scope(*required_scopes: MCPScope):
"""Decorator to enforce scope requirements."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
token = get_current_mcp_token()
token_scopes = set(token.scopes)
# Check for admin wildcard
if "admin:*" in token_scopes:
return await func(*args, **kwargs)
# Check required scopes
required = set(s.value for s in required_scopes)
if not required.issubset(token_scopes):
missing = required - token_scopes
raise InsufficientScopeError(
f"Missing required scopes: {missing}"
)
return await func(*args, **kwargs)
return wrapper
return decorator
# Usage
@router.post("/entities/{entity_type}")
@require_scope(MCPScope.WRITE_ENTITIES)
async def create_entity(entity_type: str, data: dict):
...
@router.delete("/entities/{entity_type}/{id}")
@require_scope(MCPScope.DELETE_ENTITIES)
async def delete_entity(entity_type: str, id: str):
...
Token Creation with Scopes:
# Only grant minimum required scopes
@router.post("/mcp/tokens")
async def create_mcp_token(
request: MCPTokenRequest,
current_user: User = Depends(get_current_user),
):
# Validate requested scopes against user permissions
allowed_scopes = get_user_allowed_scopes(current_user)
requested_scopes = set(request.scopes)
if not requested_scopes.issubset(allowed_scopes):
raise HTTPException(
status_code=403,
detail=f"Cannot grant scopes: {requested_scopes - allowed_scopes}"
)
return await mcp_token_service.create_token(
user_id=current_user.id,
scopes=list(requested_scopes),
ttl_days=min(request.ttl_days, 90), # Max 90 days
)
References
ADR-021 | Auth Layer | Implemented | APPROVED_WITH_MODS Completed