Skip to main content

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

  • 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:

  1. Client Type: Non-browser clients (Claude Desktop, scripts)
  2. Token Format: Self-contained for stateless validation
  3. Rotation: Long-lived tokens with explicit revocation
  4. Audit Trail: Track token issuance and usage
  5. 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

  1. JWT Format: Self-contained tokens with claims
  2. Custom Type Claim: type: "mcp_access" distinguishes from UI
  3. 24-Hour Expiration: Balance security and usability
  4. Token Storage: Redis with memory fallback
  5. 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:

  1. 24-Hour Expiration Too Short: Forces daily reconfiguration of Claude Desktop (edit JSON + restart)
  2. Missing JWT Claims: Need aud (audience) and iss (issuer) for security
  3. Memory Fallback Risk: Redis must be mandatory for revocation in production
  4. Multi-Tenant Scoping: Token must include explicit tenant/org identifier

Required Modifications:

  1. 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
  2. Add Standard JWT Claims:
    {
    "aud": "mcp.yourservice.com",
    "iss": "auth.yourservice.com",
    "tid": "tenant_123"
    }
  3. Token Prefix for Secret Scanning: Use mcp-sk-eyJ... format
  4. Redis Mandatory in Production: Memory fallback breaks multi-instance revocation
  5. Consider RS256: For multi-tenant environments with distributed validation

Modifications Applied

  1. Added tiered expiration model (24h/30d/90d)
  2. Enhanced token structure with aud, iss, and tenant claims
  3. Documented token prefix convention for secret scanners
  4. Redis marked as required for production deployments
  5. 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:

TriggerActionNew TTL
Scheduled (30 days)Automatic rotation30 days
Security incidentImmediate revocation24 hours
User requestManual rotation30 days
Scope changeNew token requiredOriginal

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:

ScopeDescriptionAccess Level
read:entitiesRead all entitiesRead
write:entitiesCreate/update entitiesWrite
delete:entitiesDelete entitiesAdmin
read:metricsRead SLO/SLI metricsRead
write:runbooksExecute runbooksExecute
admin:systemSystem administrationAdmin

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