Understanding Task-Based Access Control
Task-Based Access Control (TBAC) is an authorization model for AI agents that enforces permissions based on what work is being done rather than who is doing it.
Why Traditional Access Control Fails for AI Agents
AI agents run dynamic workflows that span multiple systems. Traditional models create mismatches:
Model | Issue | Example |
---|---|---|
RBAC (Role-Based) | Static roles don't match dynamic workflows | Agent needs "Finance Manager" role to check budgets, but inherits payroll and forecast access |
ABAC (Attribute-Based) | Attribute combinations explode in complexity | Managing hundreds of time/location/resource attributes becomes unmaintainable |
Impersonation | Accountability breaks down | Logs show user identity, not autonomous agent action |
The core problem: These models assume relatively stable human user permissions. AI agents need different access for different workflows—sometimes changing minute by minute.
The Three Dimensions of TBAC
TBAC enforces authorization through three progressive layers:
1. Tasks
Business-level objectives the agent is authorized to perform.
"authorized_tasks": ["expense_approval", "financial_reporting"]
Purpose: Validate the agent is authorized for this type of work before checking system access.
Example: An expense agent can perform "expense_approval" tasks but cannot perform "payroll_management" tasks.
2. Tools
MCP server and tool access that controls which specific MCP servers and their exposed tools the agent can invoke.
In MCP, tools are functions that servers expose (like get_weather
, query_database
, send_email
). These are the actual capabilities invoked via tools/call
.
Purpose: Limit lateral movement—even if authorized for a task, the agent can only invoke specific MCP tools from specific servers.
Choosing Your JWT Structure
You have two options for structuring tool permissions in your JWT. Your choice depends on whether you need server-specific constraints, not on how many MCP servers you have.
Option 1: Hierarchical structure—Use when you need per-server constraints
"tools": {
"github_mcp": {
"actions": ["create_issue", "add_comment"],
"allowed_repos": ["org/frontend", "org/backend"]
},
"slack_mcp": {
"actions": ["post_message"],
"allowed_channels": ["#incidents", "#engineering"]
}
}
When to use: When different MCP servers need different constraints (like allowed repositories, channels, folders, or rate limits).
Policy example:
- match: "Contains(`jwt.tools.github_mcp.actions`, `${mcp.params.name}`)"
action: allow
Option 2: Flat array structure—Use for basic tool lists
"allowed_tools": ["create_issue", "post_message", "query_logs"]
When to use: When you only need to allow/deny specific tools without server-specific constraints.
Policy example:
- match: "Contains(`jwt.allowed_tools`, `${mcp.params.name}`)"
action: allow
Can Both Structures Be Used Together?
Yes! You can use both in the same JWT:
{
"allowed_tools": ["create_issue", "post_message"],
"tools": {
"github_mcp": {
"allowed_repos": ["org/frontend"]
}
},
"max_requests_per_hour": 100
}
This lets you use allowed_tools
for quick checks and nest other constraints under tools
where needed.
Example scenario: Your incident response agent might need the github_mcp
server for create_issue
(but only in specific repos), and the slack_mcp
server for post_message
(but only to specific channels).
It cannot access delete_repository
or post to #executive-announcements
.
3. Transactions
Parameter-level constraints on what the agent can do with each tool.
"approval_limit": 2500,
"department": "sales",
"categories": ["travel", "meals"]
Purpose: Enforce business rules and compliance constraints at the operation level.
Example: The agent approves expenses up to $2,500, only for the sales department, only for travel and meals categories.
How the Layers Work Together
┌─────────────────────────────────────┐
│ 1. Task Check │
│ Is "expense_approval" in │
│ authorized_tasks? │ ✅ Pass → Continue
└─────────────────────────────────────┘ ❌ Fail → Deny
┌─────────────────────────────────────┐
│ 2. Tool Check │
│ Is "submit_expense" in the │
│ expense_mcp server's actions? │ ✅ Pass → Continue
└─────────────────────────────────────┘ ❌ Fail → Deny
┌─────────────────────────────────────┐
│ 3. Transaction Check │
│ - Amount ≤ approval_limit? │
│ - Department matches? │
│ - Category allowed? │ ✅ Pass → Allow
└─────────────────────────────────────┘ ❌ Fail → Deny
Each layer constrains permissions further, ensuring minimum necessary access.
Variable Substitution: Making TBAC Scalable
The challenge with traditional policy systems: managing hundreds of agents requires thousands of policy lines.
Traditional approach:
# Separate policy for each agent
- match: "agent_id == 'agent-123' && tool == 'submit_expense'"
action: allow
- match: "agent_id == 'agent-456' && tool == 'query_budget'"
action: allow
# ... 1000s more lines
TBAC with variable substitution:
# One policy for all agents
- match: "Contains(`jwt.allowed_tools`, `${mcp.params.name}`)"
action: allow
How It Works
Variable substitution uses ${jwt.claim}
and ${mcp.parameter}
to inject runtime values into policies.
Example evaluation:
JWT token:
{
"sub": "agent:expense-bot",
"authorized_tasks": ["expense_approval"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 2500
},
"notification_mcp": {
"actions": ["send_notification"]
}
}
}
MCP request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "submit_expense",
"arguments": {
"amount": 1800,
"department": "sales",
"category": "travel"
}
}
}
Policy:
policies:
# Tool authorization: Check if tool is in expense_mcp server's actions
- match: "Contains(`jwt.tools.expense_mcp.actions`, `${mcp.params.name}`)"
action: allow
# Transaction authorization: Use server-specific limit
- match: "Lte(`mcp.params.arguments.amount`, `${jwt.tools.expense_mcp.max_amount}`)"
action: allow
Alternative: Flat Array Approach
policies:
# Tool authorization with flat array
- match: "Contains(`jwt.allowed_tools`, `${mcp.params.name}`)"
action: allow
# Transaction authorization with top-level claim
- match: "Lte(`mcp.params.arguments.amount`, `${jwt.approval_limit}`)"
action: allow
Evaluation:
${mcp.params.name}
→"submit_expense"
(the MCP tool name from request)${jwt.tools.expense_mcp.max_amount}
→2500
Contains(["submit_expense", "query_expense"], "submit_expense")
→ ✅ TRUELte(1800, 2500)
→ ✅ TRUE- Result: Allow
If the same agent requests a forbidden tool like "delete_expense"
:
Contains(["submit_expense", "query_expense"], "delete_expense")
→ ❌ FALSE- Result: Deny
Key insight: Different agents with different permissions all use the same policy lines. Agent-specific constraints come from JWT claims, not policy files.
Practical Example: Multi-Department Expense Approval
Scenario: Three expense approval agents across different departments with different limits.
JWT Tokens
- Sales Agent
- Engineering Agent
- Executive Agent
{
"sub": "agent:expense-sales",
"authorized_tasks": ["expense_approval"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 2500,
"allowed_categories": ["travel", "meals", "client_entertainment"]
},
"notification_mcp": {
"actions": ["send_notification"],
"allowed_channels": ["#sales", "#general"]
}
},
"department": "sales"
}
{
"sub": "agent:expense-engineering",
"authorized_tasks": ["expense_approval"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 5000,
"allowed_categories": ["travel", "equipment", "software"]
},
"notification_mcp": {
"actions": ["send_notification"],
"allowed_channels": ["#engineering", "#general"]
}
},
"department": "engineering"
}
{
"sub": "agent:expense-executive",
"authorized_tasks": ["expense_approval", "financial_reporting"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 25000,
"allowed_categories": ["all"]
},
"reporting_mcp": {
"actions": ["export_report", "generate_forecast"]
},
"notification_mcp": {
"actions": ["send_notification"],
"allowed_channels": ["all"]
}
},
"department": "all"
}
Single Policy for All Agents
The JWT tokens above use the hierarchical structure because each MCP server needs different constraints (expense limits for expense_mcp
, allowed channels for notification_mcp
).
Policy using hierarchical structure:
policies:
# Task authorization: Validate agent has required business objective
- match: "Contains(`jwt.authorized_tasks`, `expense_approval`)"
action: allow
# Tool authorization: Check if tool is in expense_mcp server's actions
- match: "Contains(`jwt.tools.expense_mcp.actions`, `${mcp.params.name}`)"
action: allow
# Transaction: Amount limit using server-specific constraint
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
Lte(`mcp.params.arguments.amount`, `${jwt.tools.expense_mcp.max_amount}`)
action: allow
# Transaction: Department match (unless "all")
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
(Equals(`jwt.department`, `all`) ||
Equals(`jwt.department`, `${mcp.params.arguments.department}`))
action: allow
# Transaction: Category allowed using server-specific constraint
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
(Contains(`jwt.tools.expense_mcp.allowed_categories`, `all`) ||
Contains(`jwt.tools.expense_mcp.allowed_categories`, `${mcp.params.arguments.category}`))
action: allow
defaultAction: deny
Alternative: Flat Array Structure
If you don't need server-specific constraints, you could use a simpler flat structure:
Simplified JWT (flat structure):
{
"sub": "agent:expense-sales",
"authorized_tasks": ["expense_approval"],
"allowed_tools": ["submit_expense", "query_expense", "send_notification"],
"max_amount": 2500,
"department": "sales",
"allowed_categories": ["travel", "meals", "client_entertainment"]
}
Policy using flat structure:
policies:
# Task authorization
- match: "Contains(`jwt.authorized_tasks`, `expense_approval`)"
action: allow
# Tool authorization with flat array
- match: "Contains(`jwt.allowed_tools`, `${mcp.params.name}`)"
action: allow
# Transaction: Amount limit using top-level claim
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
Lte(`mcp.params.arguments.amount`, `${jwt.max_amount}`)
action: allow
# Transaction: Department match
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
(Equals(`jwt.department`, `all`) ||
Equals(`jwt.department`, `${mcp.params.arguments.department}`))
action: allow
# Transaction: Category allowed
- match: |
Equals(`mcp.params.name`, `submit_expense`) &&
(Contains(`jwt.allowed_categories`, `all`) ||
Contains(`jwt.allowed_categories`, `${mcp.params.arguments.category}`))
action: allow
defaultAction: deny
Authorization Outcomes
Request | Sales Agent | Engineering Agent | Executive Agent |
---|---|---|---|
Submit $1,500 travel (sales) | ✅ Allowed | ❌ Wrong dept | ✅ Allowed |
Submit $3,000 equipment (eng) | ❌ Wrong category | ✅ Allowed | ✅ Allowed |
Submit $10,000 travel (exec) | ❌ Over limit | ❌ Over limit | ✅ Allowed |
Call export_report tool | ❌ Tool denied | ❌ Tool denied | ✅ Allowed |
Result: Same policy enforces different permissions for each agent based on their JWT claims.
Why TBAC Works for MCP
The Model Context Protocol (MCP) standardizes how AI agents invoke tools. Every MCP request has a consistent JSON-RPC 2.0 structure:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "tool_name",
"arguments": { /* operation-specific */ }
}
}
This structure enables TBAC enforcement at multiple levels:
- Tasks: Validated via JWT claims (
authorized_tasks
) that define business-level permissions - Tools: Enforced via
params.name
field, checked against JWT claims (eitherallowed_tools
flat array ortools.server_name.actions
hierarchical structure) - Transactions: Enforced via
params.arguments
fields, validated against JWT constraints (limits, departments, etc.)
Because MCP provides a standardized protocol, TBAC works universally across all agent frameworks and all MCP server implementations—without requiring modifications to either agents or servers.
Structuring OAuth Scopes and JWT Claims
TBAC's effectiveness depends on well-structured OAuth scopes and JWT claims. When agents authenticate with your identity provider, they request scopes, and the IdP issues a JWT with claims based on those scopes.
The Scope-to-Claims Flow
Agent requests token with scopes
↓
Identity Provider validates
↓
JWT issued with claims based on granted scopes
↓
Agent presents JWT to MCP Gateway
↓
Traefik evaluates policies using JWT claims
Granular Scope Design
Design scopes that map to TBAC dimensions:
Task-level scopes (business objectives):
scope:task:expense_approval
scope:task:server_provisioning
scope:task:refund_processing
Tool-level scopes (MCP tool names):
scope:tool:submit_expense
scope:tool:query_budget
scope:tool:send_notification
Transaction-level scopes (with parameters):
scope:expense:limit:2500
scope:expense:dept:sales
scope:expense:categories:travel,meals
JWT Claims Structure
Map granted scopes to structured claims in your JWT.
Hierarchical structure (when you need per-server constraints):
{
"sub": "agent:expense-sales",
"iss": "https://idp.example.com",
"aud": "mcp-gateway",
"exp": 1735689600,
"authorized_tasks": ["expense_approval", "expense_reporting"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 2500,
"allowed_categories": ["travel", "meals", "client_entertainment"]
},
"notification_mcp": {
"actions": ["send_notification"],
"allowed_channels": ["#sales", "#general"]
}
},
"department": "sales"
}
Flat structure (simpler):
{
"sub": "agent:expense-sales",
"iss": "https://idp.example.com",
"aud": "mcp-gateway",
"exp": 1735689600,
"authorized_tasks": ["expense_approval", "expense_reporting"],
"allowed_tools": ["submit_expense", "query_expense", "send_notification"],
"approval_limit": 2500,
"department": "sales",
"categories": ["travel", "meals", "client_entertainment"]
}
Best Practices
-
Use structured claims: Organize permissions as JSON arrays and objects, not comma-separated strings
// ✅ Good
"allowed_tools": ["api1", "api2", "api3"]
// ❌ Avoid
"allowed_tools": "api1,api2,api3" -
Namespace your claims: Prevent collisions with standard JWT claims
{
"sub": "agent:bot-123",
"permissions": {
"tasks": ["customer_followup", "generate_report"],
"tools": {
"salesforce_mcp": {
"allowed_methods": ["query_opportunities", "create_activity"],
"filters": {"status": "Open", "days_since_contact": ">30"}
},
"email_service": {
"allowed_methods": ["send"],
"allowed_domains": ["customer.com", "prospect.com"]
}
}
}
} -
Keep claims granular: One claim per constraint enables flexible policy composition
// ✅ Granular - compose in policies
{
"approval_limit": 5000,
"department": "sales",
"categories": ["travel", "meals"]
}
// ❌ Coarse - hard to compose
{
"permissions": "approve:5000:sales:travel,meals"
} -
Use appropriate data types: Numbers for limits, booleans for flags, arrays for lists
{
"approval_limit": 5000, // number, not "5000"
"can_override": true, // boolean, not "true"
"allowed_categories": ["a", "b"] // array, not "a,b"
}
Example: Scope Request and JWT Issuance
Agent requests token:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=agent-expense-sales
&client_secret=secret123
&scope=scope:task:expense_approval scope:tool:expense_mcp:submit_expense scope:expense:limit:2500
Identity Provider issues JWT (hierarchical):
{
"sub": "agent:expense-sales",
"iss": "https://idp.example.com",
"aud": "mcp-gateway",
"exp": 1735689600,
"scope": "scope:task:expense_approval scope:tool:expense_mcp:submit_expense scope:expense:limit:2500",
"authorized_tasks": ["expense_approval"],
"tools": {
"expense_mcp": {
"actions": ["submit_expense", "query_expense"],
"max_amount": 2500
}
}
}
Traefik Hub MCP Gateway policy uses claims (hierarchical):
policies:
- match: "Contains(`jwt.authorized_tasks`, `expense_approval`)"
action: allow
- match: "Contains(`jwt.tools.expense_mcp.actions`, `${mcp.params.name}`)"
action: allow
- match: "Lte(`mcp.params.arguments.amount`, `${jwt.tools.expense_mcp.max_amount}`)"
action: allow
defaultAction: deny
Alternative with Flat Structure
policies:
- match: "Contains(`jwt.authorized_tasks`, `expense_approval`)"
action: allow
- match: "Contains(`jwt.allowed_tools`, `${mcp.params.name}`)"
action: allow
- match: "Lte(`mcp.params.arguments.amount`, `${jwt.approval_limit}`)"
action: allow
defaultAction: deny
IdP-Driven Access Control
This architecture creates IdP-driven access control where authorization decisions are based on cryptographically verified JWT claims:
- Identity Provider issues JWT: The IdP creates and signs the JWT token with the agent's permissions encoded as claims
- MCP Gateway verifies authenticity: Traefik validates the JWT signature using cryptographic keys (typically fetched from the IdP's JWKS endpoint), ensuring the token hasn't been tampered with
- Gateway enforces policies: Once verified, Traefik uses the trusted claims to make authorization decisions
Key security property: The MCP Gateway never modifies or creates permissions—it only enforces what the IdP has cryptographically attested to. This creates a clear trust boundary:
- IdP owns authorization decisions: What permissions should this agent have?
- Gateway owns policy enforcement: Does this request comply with the agent's permissions?
Benefits of IdP-driven control:
- Centralized permission management: Update agent permissions in your IdP without changing gateway policies
- Cryptographic verification: JWT signatures prove claims came from your trusted IdP
- Audit trail: Identity provider logs show permission grants; gateway logs show enforcement decisions
- Standards-based: Uses OAuth 2.0/2.1 and JWT standards—works with any compliant IdP (Okta, Auth0, Azure AD, Keycloak)
Separation of concerns:
- Identity Provider: Manages scope definitions, claim mappings, and permission lifecycle
- MCP Gateway: Verifies JWT authenticity and enforces declarative policies using verified claims
- Policies: Declarative, claim-based rules that remain stable as agent permissions change
This model ensures that access control is driven by your identity infrastructure, not hardcoded in gateway configuration. Change an agent's permissions in your IdP, and the gateway automatically enforces the new permissions on the next request—no gateway restart or configuration changes required.
TBAC and the Triple Gate Pattern
TBAC is the second gate in Traefik Hub's Triple Gate Pattern for comprehensive AI security:
- AI Gateway - Secures the conversation layer (prompt injection, jailbreaks, PII extraction)
- MCP Gateway with TBAC - Governs tool access with task/tool/transaction authorization
- API Gateway - Protects backend services (rate limiting, authentication, input validation) Each gate enforces policies appropriate to its layer. To compromise the system, all three gates must be defeated simultaneously, dramatically reducing your vulnerability surface.
Next Steps
- Learn how to Get Started with MCP Gateway
- Learn how to Configure MCP Middleware