Camp 1: Identity & Access Management¶
Establishing Your Identity on the Mountain
Welcome to Camp 1, where you'll establish production-grade identity controls for your MCP server. In Base Camp, you learned that unauthenticated servers are dangerous. Now we'll deploy to Azure and implement enterprise security using Managed Identity, Key Vault, and OAuth 2.1 with JWT validation.
This camp demonstrates why the same vulnerabilities from Base Camp are even more dangerous in the cloud, and how Azure's identity services provide passwordless, production-grade solutions. You'll follow the same "vulnerable → exploit → fix → validate" methodology, but this time in a real cloud environment with real-world security controls.
Tech Stack: Python, FastMCP, Azure Container Apps, Entra ID, Key Vault, and Managed Identity
Primary Risks: MCP01 (Token Mismanagement), MCP07 (Insufficient Authentication), MCP02 (Privilege Escalation)
What You'll Learn¶
Building on Base Camp's foundation, you'll master enterprise-grade identity and access management in Azure:
Learning Objectives
- Deploy an MCP server to Azure Container Apps
- Understand cloud-specific security vulnerabilities (tokens in Portal, no expiration)
- Implement Azure Managed Identity for passwordless Azure resource access
- Secure secrets with Azure Key Vault
- Configure OAuth 2.1 with Entra ID for client authentication
- Validate JWT tokens including audience checking to prevent confused deputy attacks
- Apply least-privilege RBAC principles
Prerequisites¶
Before starting Camp 1, ensure you have the required tools installed.
Prerequisites Guide
See the Prerequisites page for detailed installation instructions, verification steps, and troubleshooting.
Quick checklist for Camp 1:
Azure subscription with Contributor access
Azure CLI (authenticated)
Azure Developer CLI - azd (authenticated)
Python 3.10+
uv (Python package installer)
Docker (installed and running)
Completed Base Camp (recommended)
If you haven't installed these tools yet, visit the Prerequisites page for detailed installation instructions and verification steps.
The Ascent¶
Camp 1 follows six waypoints, each building on the previous one. Click each waypoint below to expand instructions and continue your ascent.
Waypoint 1: Deploy Vulnerable Server to Azure
Deploy to Azure Container Apps¶
The vulnerable server uses the same StaticTokenVerifier pattern from Base Camp, but now deployed to Azure where the vulnerabilities become even more dangerous.
What is StaticTokenVerifier? (Optional - Skip if you completed Base Camp)
If you skipped Base Camp, here's what you need to know:
StaticTokenVerifier is a simple (and insecure) authentication method that checks incoming requests against a hardcoded list of valid tokens:
# Example: How the vulnerable server "authenticates"
auth = StaticTokenVerifier(
tokens={
"camp1_demo_token_INSECURE": {"client_id": "user_001"}
}
)
Why this is insecure:
- Tokens are hardcoded - Stored in plain text in environment variables
- No expiration - Once issued, valid forever
- No rotation - Can't change tokens without redeploying
- No cryptographic validation - Just string matching
- No user context - Can't tell who's actually using the token
- Easy to steal - Visible in Portal, logs, and code
In this camp, we'll migrate from this vulnerable pattern to JWTVerifier with OAuth 2.1, which solves all these problems using industry-standard authentication with Microsoft Entra ID.
Let's start by provisioning the Azure infrastructure and deploying both servers:
When prompted:
- Environment name: Choose a name (e.g.,
camp1-dev) - Subscription: Select your Azure subscription
- Resource group: Create new or select existing (e.g.,
rg-camp1-dev) - Location: Select your Azure region (e.g.,
eastusorwestus2)
This provisions all Azure resources:
- Resource group
- Container Registry with Managed Identity access
- Log Analytics workspace
- Container Apps Environment
- Key Vault with RBAC for Managed Identity
- Managed Identity with proper role assignments
- Both Container Apps (vulnerable-server and secure-server)
Next, deploy the application code:
This builds and deploys your Python MCP servers:
- Builds Docker images for both servers
- Pushes images to Azure Container Registry
- Updates Container Apps with the new images
What Just Deployed?¶
The vulnerable server is now running in Azure with:
❌ Token stored in plain-text environment variables
❌ Token never expires
❌ No audience validation
❌ Secrets visible in Azure Portal
This demonstrates OWASP MCP01 (Token Mismanagement) and MCP07 (Insufficient Auth) in a cloud environment!
Save Your Deployment Information¶
# Get your deployment info
azd env get-values | grep -E "VULNERABLE_SERVER_URL|SECURE_SERVER_URL|AZURE_RESOURCE_GROUP|AZURE_KEY_VAULT"
Keep these values handy - you'll need them for the exploits!
Waypoint 2: Exploit Cloud Vulnerabilities
Cloud Deployment Amplifies Security Risks¶
The same vulnerabilities from Base Camp are more critical in Azure because:
- Tokens are visible in Azure Portal (not just in code)
- Audit logs expose tokens (compliance violation)
- Wider attack surface (anyone with read access can steal tokens)
- Persistent deployment (vulnerable server runs 24/7, not just during development)
Exploit 2.1: Steal Token from Portal & Use It Forever¶
The vulnerability: Static tokens stored in environment variables are visible in the Azure Portal to anyone with read access, and they never expire.
Steps to exploit:
- Open Azure Portal
- Navigate to your resource group (e.g.,
rg-camp1-dev) - Click on the vulnerable server Container App (named
ca-vulnerable-xxxxx) - In the left menu, go to Application → Containers
- Click the Environment variables tab
- Find
REQUIRED_TOKENwith valuecamp1_demo_token_INSECURE- it's right there in plain text!
Try it yourself: Copy the stolen token and use it to authenticate:
# Get your vulnerable server URL
VULNERABLE_URL=$(azd env get-values | grep VULNERABLE_SERVER_URL | cut -d= -f2 | tr -d '"')
# Test with the stolen token - server accepts it!
curl -X POST ${VULNERABLE_URL}/mcp \
-H "Authorization: Bearer camp1_demo_token_INSECURE" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"exploit-test","version":"1.0"}},"id":1}'
What you'll see: The server returns a successful response. The MCP server can't tell the difference between a legitimate request and your stolen token - it just works!
Now wait an hour... or a day... or even a month... Run the same command again - it STILL works! The token never expires.
Security Impact: Double Threat
Easy to Steal:
- Anyone with Reader access to the Container App can steal the token
- Developers, operations teams, security auditors all have this access
- Token appears in audit logs (compliance violation)
- Compromised Azure accounts gain immediate access
- No way to detect if token was stolen
Impossible to Revoke:
- Stolen token can be used indefinitely - no expiration
- No token rotation mechanism
- No way to revoke access without redeploying the entire application
- Even if you discover the breach, you can't disable the token
- A single breach = permanent compromise
This demonstrates both OWASP MCP01 (Token Mismanagement) and MCP07 (Insufficient Authentication) - static tokens are visible to too many people AND they never expire!
Exploit 2.2: No Audience Validation (Conceptual)¶
Even if we were using JWTs (we're not yet), the StaticTokenVerifier doesn't validate the aud (audience) claim.
What this means:
- A token intended for Service A could be used with Service B
- This is called a confused deputy attack
- The server can't distinguish "is this token meant for me?"
Example scenario:
- Alice gets a JWT for accessing the payment service
- She uses that same JWT to access the user data service
- Both services accept it because neither checks audience
We'll fix this in Waypoint 5 with proper JWT validation including audience checking.
Summary of Exploits¶
| Exploit | Impact | OWASP Risk |
|---|---|---|
| Steal token from Portal & use forever | Anyone with Portal access gets permanent access | MCP01, MCP07 |
| No audience check | Confused deputy attacks | MCP07 |
Waypoint 3: Enable Managed Identity
What is Managed Identity?¶
Azure Managed Identity eliminates passwords and keys by having Azure automatically manage credentials for you:
No secrets to store - Azure handles authentication
No secrets to rotate - Azure manages the lifecycle
Uses Azure RBAC - Permissions controlled by role assignments
Works with many Azure services - Key Vault, Storage, Cosmos DB, etc.
How it works:
- Your Container App has a Managed Identity (automatically created)
- You grant that identity RBAC permissions (e.g., "Key Vault Secrets User")
- Your code uses
DefaultAzureCredential()- automatically picks up the identity - No passwords, no keys, no secrets!
Verify Managed Identity Setup¶
Your infrastructure already created the Managed Identity during the provision process. Let's verify it:
This script:
- Loads your azd environment variables
- Verifies the Managed Identity exists
- Confirms RBAC role assignments to Key Vault
Expected output:
🔐 Camp 1: Enable Managed Identity
==================================
📦 Loading azd environment...
✅ Managed Identity Principal ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
🔍 Verifying Key Vault role assignment...
Role Scope
-------------------------- --------------------------------------------------
Key Vault Secrets User /subscriptions/.../providers/Microsoft.KeyVault/...
✅ Managed Identity setup complete!
The Container App can now access Key Vault secrets without passwords.
Understanding the Security Improvement¶
Before (vulnerable):
# Hardcoded connection string - BAD!
CONNECTION_STRING = "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=EXPOSED_KEY..."
client = BlobServiceClient.from_connection_string(CONNECTION_STRING)
After (secure with Managed Identity):
from azure.identity import DefaultAzureCredential
# No secrets! Managed Identity authenticates automatically
credential = DefaultAzureCredential()
client = BlobServiceClient(account_url="https://storage.blob.core.windows.net", credential=credential)
How This Protects You¶
| Threat | Before | After |
|---|---|---|
| Credential theft | Keys in env vars | No keys to steal |
| Rotation burden | Manual rotation | Azure auto-rotates |
| Portal exposure | Visible to readers | Not visible (identity reference only) |
| Code leaks | Keys in repo | No keys in code |
| Over-privileged | Often admin keys | Least-privilege RBAC |
Next Step¶
Managed Identity is configured! Now let's use it to access Key Vault in Waypoint 4.
Waypoint 4: Migrate Secrets to Key Vault
What is Azure Key Vault?¶
Azure Key Vault is a cloud service for securely storing and accessing:
- Secrets: API keys, connection strings, passwords
- Keys: Encryption keys for cryptographic operations
- Certificates: SSL/TLS certificates
Benefits:
- Centralized secret management - One place for all secrets
- Access auditing - Who accessed what, when
- Secret rotation - Update secrets without redeploying
- RBAC-based access - Fine-grained permissions
- Versioning - Keep history of secret changes
Create Secrets in Key Vault¶
Let's migrate demo secrets from environment variables to Key Vault:
This script:
- Creates sample secrets in your Key Vault
demo-api-key- Example API keyexternal-service-secret- Example service credential
Expected output:
🔑 Camp 1: Migrate Secrets to Key Vault
=======================================
📦 Loading azd environment...
Creating demo secrets in Key Vault: kv-sherpa-camp1-xxxxx
📝 Creating demo-api-key...
📝 Creating external-service-secret...
✅ Secrets created in Key Vault!
📋 Current secrets:
Name Enabled
-------------------------- ---------
demo-api-key True
external-service-secret True
How the Secure Server Accesses Key Vault¶
The secure server (which we'll deploy in Waypoint 5) uses Managed Identity to access Key Vault:
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
def get_keyvault_secret(secret_name: str) -> str:
# Managed Identity authenticates automatically!
credential = DefaultAzureCredential()
client = SecretClient(vault_url=KEY_VAULT_URL, credential=credential)
return client.get_secret(secret_name).value
# Usage - no hardcoded secrets!
api_key = get_keyvault_secret("demo-api-key")
Verify Secrets in Azure Portal¶
- Open Azure Portal
- Navigate to your Key Vault (e.g.,
kv-sherpa-camp1-xxxxx) - Go to Objects → Secrets
- You'll see your secrets listed, but values are hidden
- Click a secret → Click current version → Click "Show Secret Value"
- Notice: You need explicit permission to view secret values!
Security Improvements¶
| Aspect | Before (Env Vars) | After (Key Vault) |
|---|---|---|
| Visibility | Anyone with read access sees values | Values hidden, audit logged |
| Rotation | Requires redeployment | Update in Key Vault, no redeploy |
| Access Control | All-or-nothing (Portal access) | Fine-grained RBAC per secret |
| Audit | No audit trail | Every access logged |
| Versioning | No history | Full version history |
Best Practices Applied¶
Separation of Concerns: Secrets managed separately from application code
Least Privilege: Managed Identity has only "Key Vault Secrets User" role
Defense in Depth: RBAC + audit logs + encryption at rest
Compliance Ready: Audit logs for SOC 2, ISO 27001, etc.
Waypoint 5: Upgrade to OAuth 2.1 with JWT Validation
Static tokens served us well in Waypoint 4, but they have a fatal flaw: they never expire. If someone gets hold of camp1_demo_token_INSECURE, they have permanent access—and there's no way to revoke it. Time to upgrade to OAuth 2.1 with Microsoft Entra ID.
In this waypoint, you'll replace static token authentication with cryptographically-signed JWT tokens (RFC 7519) that expire after an hour. Your secure server will validate every token's signature, audience, issuer, and expiration - eliminating the risks of hardcoded credentials. You'll test two OAuth flows: Device Code Flow (perfect for CLI tools) and Authorization Code + PKCE (the production-ready browser flow).
As a bonus, you'll implement Protected Resource Metadata (RFC 9728)—a standard that lets OAuth clients automatically discover your server's authentication requirements. No more manual configuration. Just give a client your URL, and PRM handles the rest. This is how modern MCP clients like VS Code, Claude Desktop, and GitHub Copilot will connect to your server in the future.
What is OAuth 2.1?
OAuth 2.1 is the modern authentication standard that fixes the security issues of static tokens:
- Tokens expire - Short-lived tokens reduce breach impact
- PKCE (Proof Key for Code Exchange) - Prevents token interception
- Audience validation - Tokens are tied to specific services
- JWT (JSON Web Tokens) - Cryptographically signed, tamper-proof
- Integration with Entra ID - Enterprise identity provider
How it works:
- Client authenticates with Entra ID (Microsoft's identity platform)
- Entra ID issues a JWT token (valid for ~1 hour)
- Client sends JWT to MCP server
- Server validates: signature, issuer, audience, expiration
- If valid, server processes request
Step 5a: Register Entra ID Application¶
This script creates and configures an Entra ID app registration with:
- OAuth 2.1 scope (
access_as_user) for delegated permissions - Device Code Flow support for CLI authentication
- Authorization Code + PKCE support for browser-based flows
- Protected Resource Metadata (PRM) endpoints for OAuth discovery
- Pre-authorized clients:
- Azure CLI (
04b07795-8ddb-461a-bbee-02f9e1bf7b46) - for Device Code Flow - VS Code (
aebc6443-996d-45c2-90f0-388ff96faa56) - for future MCP client support
- Azure CLI (
This enables both authentication methods (Option A and B) with a single registration.
Expected output:
🔐 Camp 1: Register Entra ID Application
========================================
Creating Entra ID app registration: sherpa-mcp-camp1-1234567890
✅ App ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Setting identifier URI...
Exposing API scope...
✅ API scope created
Pre-authorizing clients (Azure CLI + VS Code)...
✅ Clients pre-authorized
✅ Redirect URIs configured
Public client: device code flow
Web: VS Code OAuth, demo client (port 8090)
✅ Client type configured (confidential - supports client secrets)
✅ Entra ID Application Registered!
====================================
App Name: sherpa-mcp-camp1-1234567890
Client ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Tenant ID: yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
Identifier URI: api://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
✅ Pre-authorized clients:
- Azure CLI (for Device Code Flow)
- VS Code (for PRM-based authentication)
✅ Redirect URIs configured:
- urn:ietf:wg:oauth:2.0:oob (device code flow)
- http://127.0.0.1:33418 (VS Code)
- https://vscode.dev/redirect (VS Code)
- http://localhost:8090/callback (demo client)
📝 Save these values - you'll need them for deployment!
Add to your .env file:
AZURE_TENANT_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Save these values! You'll need them for deployment.
What's happening behind the scenes?
Creating a "Doorman" for Your Server
Think of your MCP server as a building that needs security. This script creates a "doorman" (Entra ID app registration) who knows:
- Who's allowed in (Azure CLI, VS Code, and your demo client)
- What they can do (access the MCP server on your behalf)
- How to verify their ID (checking OAuth tokens)
Step-by-step breakdown:
1. Create the app registration
This creates a unique identity for your MCP server in Azure. The Client ID is like a serial number - it uniquely identifies your app in Microsoft's identity system. Your actual Client ID will be different - a unique GUID generated just for you.2. Set identifier URI
This creates a globally unique "address" for your server. When clients request access, they say "I want to accessapi://xxxxxxxx..." - this prevents confusion with other apps.
3. Expose API scope
This defines what permission clients are asking for: "access the MCP server as the currently signed-in user". It's like saying "I'm not asking for admin access, just let me do what the logged-in user is allowed to do."4. Pre-authorize trusted clients
✅ Clients pre-authorized
- Azure CLI (04b07795-8ddb-461a-bbee-02f9e1bf7b46)
- VS Code (aebc6443-996d-45c2-90f0-388ff96faa56)
5. Configure redirect URIs
✅ Redirect URIs configured
Public client: urn:ietf:wg:oauth:2.0:oob (device code flow)
Web: localhost:8090/callback (demo client), VS Code endpoints
6. Configure client type
✅ Client type configured (confidential)
isFallbackPublicClient: false
Supports: Client secrets, Authorization Code flow
- Confidential client (what we have): Can use secrets for token exchange, suitable for backend apps
- Public client: Cannot store secrets securely, used for mobile/desktop apps
Production Consideration
While this demo uses client secrets for simplicity, production environments should prefer:
- Device Code Flow (Option A) - No secrets needed, great for CLI tools
- Managed Identity - For Azure-hosted services (no secrets to manage)
- Certificate-based authentication - More secure than client secrets
Why this matters:
- No more hardcoded passwords! Instead of storing a static token like
camp1_demo_token_INSECURE, your server will validate cryptographically signed tokens from Microsoft. - Tokens expire automatically - even if someone steals a token, it only works for about an hour.
- You can revoke access - if something goes wrong, you can disable the app registration and all tokens immediately stop working.
- Full audit trail - Microsoft logs every authentication, so you know who accessed what and when.
Real-world analogy:
Before (static token): Like having one key that everyone shares, never changes, and works forever. If anyone copies it, they have permanent access.
After (OAuth with Entra ID): Like having a security badge system where:
- Each person gets their own temporary badge
- Badges expire daily
- The security desk (Entra ID) keeps a log of who came in
- Lost badges can be deactivated instantly
- Only approved badge readers (Azure CLI, demo client, VS Code) work with your doors
Step 5b: Configure Secure Server with Entra ID¶
Update your azd environment with the Entra ID values:
# Replace with your actual values from the script output
azd env set AZURE_CLIENT_ID "<your-client-id>"
azd env set AZURE_TENANT_ID "<your-tenant-id>"
Now configure the secure server to use these values for JWT validation:
What this script does:
This updates the Container App's environment variables to use your Entra ID application client ID for JWT validation (instead of the Managed Identity client ID). The container automatically restarts to pick up the new configuration—no redeploy needed!
Why do we need two different Client IDs?
Understanding the Two Identities
Your deployment actually has two separate identities in Azure:
-
Managed Identity Client ID - The identity of your Container App itself
- Created automatically when you provisioned infrastructure
- Used by the Container App to authenticate TO other Azure services (like Key Vault)
- Think of it as "who the app is" when talking to Azure
-
Entra ID App Registration Client ID - The identity users authenticate WITH
- Created by the
register-entra-app.shscript - Used to validate JWT tokens FROM users
- Think of it as "who the app represents" when users sign in
- Created by the
The Key Difference:
- Managed Identity (app → Azure): "I'm Container App XYZ, let me read secrets from Key Vault"
- App Registration (user → app): "I'm a user with a token for App ABC, let me access the MCP server"
What happens without this configuration:
If you skip this step, the Container App would try to validate JWT tokens against the Managed Identity Client ID instead of your App Registration Client ID. This means:
❌ User tokens would have the wrong aud (audience) claim
❌ JWT validation would fail with "Invalid audience"
❌ Users couldn't authenticate even with valid tokens
Real-world analogy:
- Managed Identity = Your company badge (authenticates you TO the building)
- App Registration = Your customer portal (authenticates customers TO you)
You wouldn't use your company badge to verify customer identities - same principle here!
What the script sets:
# Sets AZURE_CLIENT_ID to your App Registration ID
# This tells JWTVerifier: "Expect tokens with aud=<app-registration-client-id>"
This ensures the server validates tokens against the correct identity.
The secure server now includes:
JWTVerifier for token validation
Protected Resource Metadata (PRM) endpoint at /.well-known/oauth-protected-resource
Audience validation (checks the aud claim)
Expiration checking (rejects expired tokens)
Signature validation (ensures token not tampered)
Issuer validation (confirms token from correct Entra ID tenant)
What's different in the code:
# Before (vulnerable server):
auth = StaticTokenVerifier(
tokens={"camp1_demo_token_INSECURE": {"client_id": "user_001"}}
)
# After (secure server):
auth = JWTVerifier(
jwks_uri=f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys",
audience=CLIENT_ID, # ✅ Audience validation!
issuer=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
)
Step 5c: Authenticate (Choose Your Path)¶
This camp offers two authentication methods. Both demonstrate OAuth 2.1 security - choose based on your needs:
| Method | Best For | What You'll Learn |
|---|---|---|
| Option A: Device Code Flow | CLI tools, understanding OAuth mechanics | See the token, decode it, understand JWT claims |
| Option B: Authorization Code + PKCE Demo | Browser-based flows, production patterns | Complete OAuth flow with PRM discovery |
Recommendation
Try both paths to understand different OAuth grant types:
- Start with Option A to understand what's inside a JWT token
- Then try Option B to see PRM discovery and authorization code flow in action
Option A: Device Code Flow (Understaning OAuth)
Best for: Learning OAuth mechanics, CLI automation, headless environments
This flow helps you understand JWT tokens by making them visible:
What happens:
- Script opens browser for authentication
- You sign in with your Azure account
- Azure CLI receives a JWT token
- Token is printed to terminal (you can decode it at jwt.ms)
What's happening behind the scenes?
OAuth Delegated Permissions Flow
When you run the token script:
- Azure CLI requests a token with scope
api://{YOUR_CLIENT_ID}/access_as_user - You authenticate with your Azure credentials (browser popup)
- Entra ID issues a JWT token containing:
aud(audience): Your app's client IDiss(issuer): Your Entra ID tenantscp(scope):access_as_userexp(expiration): ~1 hour from now- Your identity claims (
name,email, etc.)
Token validation on the server:
verifier = JWTVerifier(
issuer=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
audience=CLIENT_ID,
jwks_uri=f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
)
# Validates: signature, expiration, audience, issuer
Why this is more secure:
- Tokens expire automatically (can't be used forever)
- Tokens are tied to user identity (audit trail)
- Tokens can be revoked via Entra ID
- No secrets stored in environment variables
Save your token for testing:
Test with curl:
# Get secure server URL (strip quotes)
SECURE_URL=$(azd env get-values | grep SECURE_SERVER_URL | cut -d= -f2 | tr -d '"')
# Step 1: Initialize MCP session and capture session ID from response headers
RESPONSE=$(curl -i -X POST ${SECURE_URL}/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}},"id":1}')
SESSION_ID=$(echo "$RESPONSE" | grep -i "mcp-session-id:" | awk '{print $2}' | tr -d '\r')
echo "Session ID: $SESSION_ID"
# Step 2: List available tools using the session ID
curl -s -X POST ${SECURE_URL}/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: ${SESSION_ID}" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
Success! You should see a list of available tools returned, proving JWT authentication works!
Troubleshooting authentication issues
Problem: No session ID received (empty response)
This usually means authentication failed. Check:
- Is your token expired?
Tokens expire after ~1 hour. Get a new token:
- Is the audience correct?
If they don't match, you may need to:
- Ensure configure-secure-server.sh was run
- Verify AZURE_CLIENT_ID is set correctly in the Container App
- See the full error response:
# Remove the SESSION_ID extraction to see full output curl -v -X POST ${SECURE_URL}/mcp \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}},"id":1}'
Look for:
- 401 Unauthorized - Token is invalid/expired/wrong audience
- 403 Forbidden - Token valid but lacks permissions
- 500 Internal Server Error - Server configuration issue
Problem: curl shows transfer stats but no output
This happens when the response has no body. Check:
# Use -v flag to see headers and status code
curl -v -X POST ${SECURE_URL}/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "mcp-session-id: ${SESSION_ID}" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
Common causes:
- Missing or invalid mcp-session-id header
- Wrong HTTP method (should be POST)
- Incorrect endpoint URL
What you just did:
Authenticated with a JWT token (expires in ~1 hour, not forever!)
Server validated the token signature against Entra ID public keys
Server checked the audience (token is for THIS app, not another)
Server verified expiration (token is still valid)
Successfully called MCP methods with OAuth 2.1 security!
Option B: Authorization Code + PKCE Demo (Production OAuth Flow)
Best for: Understanding browser-based OAuth, PRM discovery, production authentication patterns
This demo shows how modern MCP clients discover OAuth configuration and perform the complete authorization code + PKCE flow with Entra ID.
What is Protected Resource Metadata (PRM)?
Protected Resource Metadata (PRM) is a standardized way for OAuth resource servers to advertise their authentication requirements. It's defined in RFC 9728 and enables automatic OAuth discovery.
The Problem It Solves:
Without PRM, every time you want to connect to a protected API, you need to manually configure:
- Which authorization server to use (e.g., Entra ID, Auth0, Okta)
- What scope to request (e.g.,
api://my-app/access_as_user) - How to send the token (header, query param, etc.)
This is tedious and error-prone. Users have to read documentation, copy-paste URLs, and manually configure clients.
How PRM Works:
When a client connects to your protected resource without authentication:
-
Server returns
401 Unauthorizedwith a special header: -
Client fetches the PRM endpoint and gets:
-
Client automatically knows:
- Which OAuth server to use
- What scope to request
- How to send the access token
Real-world analogy:
- Without PRM: "Here's a restaurant. Go figure out their menu, hours, and payment methods yourself."
- With PRM: "Here's a restaurant with a sign outside that lists everything you need to know."
Why It Matters for MCP:
Future MCP clients (like VS Code with MCP, Claude Desktop, GitHub Copilot) can connect to your server with zero manual configuration. Users just provide the URL, and everything else happens automatically.
RFC 9728: PRM is an official IETF standard that's part of the modern OAuth ecosystem. By implementing it, your MCP server follows industry best practices.
Run the PRM Demo Client¶
We've built a Python client that demonstrates the complete PRM + PKCE flow:
Step 1: Navigate to camp1-identity
Step 2: Generate client secret for token exchange
This creates a client secret for local testing (expires in 30 days). The secret is saved to demo-client/.env and is git-ignored.
Client Secrets in Production
This demo uses a client secret for simplicity, but production public clients should use:
- Device Code Flow (Option A) for CLI tools
- Authorization Code + PKCE without secrets for native/mobile apps
- Or implement backend-for-frontend (BFF) pattern
Client secrets are appropriate for confidential clients (server-to-server) but not for public clients in production.
Step 3: Run the demo
# Get your configuration
eval "$(azd env get-values | sed 's/^/export /')"
# Run the demo (uv handles dependencies automatically)
cd demo-client
uv run --project .. python mcp_prm_client.py \
"${SECURE_SERVER_URL}" \
"${AZURE_CLIENT_ID}"
What Happens¶
The demo will walk through each phase of the OAuth flow:
Phase 1: PRM Discovery
✓ Received WWW-Authenticate header
Bearer resource_metadata="https://your-server/.well-known/oauth-protected-resource"
✓ Found PRM endpoint
✓ Fetched PRM metadata:
Resource: https://your-server.azurecontainerapps.io
Authorization Server: https://login.microsoftonline.com/.../v2.0
Scopes: api://your-client-id/access_as_user
Phase 2: Authorization Server Discovery
✓ Fetching: https://login.microsoftonline.com/.../.well-known/openid-configuration
✓ Authorization endpoint discovered
✓ Token endpoint discovered
Phase 3: PKCE Authorization Code Flow
✓ Generated PKCE code_challenge
✓ Opening browser for authentication...
✓ Received authorization code
✓ State validated
✓ Exchanging authorization code for access token...
Using client secret from .env file
✓ Access token acquired
Token type: Bearer
Expires in: 3894 seconds
Phase 4: Authenticated MCP Requests
✓ Sending request to: https://your-server/mcp
Method: tools/list
✓ Success! Tools listed with JWT authentication
What You Just Did¶
PRM Discovery - Server told client how to authenticate (RFC 9728)
OAuth Server Discovery - Client found Entra ID endpoints automatically
PKCE Flow - Secure authorization code exchange with proof key
JWT Token - Received signed token from Entra ID (expires in ~1 hour)
Authenticated MCP - Made MCP requests with Bearer token
This is exactly how production MCP clients will work once they fully implement PRM support!
Verify PRM Endpoint Manually¶
You can also check the PRM endpoint directly:
SECURE_URL=$(azd env get-values | grep SECURE_SERVER_URL | cut -d= -f2 | tr -d '"')
# Check WWW-Authenticate header on 401
curl -i "${SECURE_URL}/mcp" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":1}'
Look for:
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://your-server/.well-known/oauth-protected-resource"
Fetch the PRM metadata:
Expected output:
{
"resource": "https://your-app.azurecontainerapps.io",
"authorization_servers": [
"https://login.microsoftonline.com/{tenant-id}/v2.0"
],
"scopes_supported": [
"api://{client-id}/access_as_user"
],
"bearer_methods_supported": ["header"],
"token_formats_supported": ["jwt"]
}
PRM Implementation Complete!
Your server now implements RFC 9728 Protected Resource Metadata. When MCP clients (VS Code, Claude Desktop, etc.) add full PRM support for pre-registered OAuth apps, they'll be able to connect to your server automatically with zero configuration!
Explore the Demo Code
The demo client (demo-client/mcp_prm_client.py) is fully commented and demonstrates the complete OAuth flow:
- PRM discovery from WWW-Authenticate header
- OAuth server metadata parsing (.well-known/openid-configuration)
- PKCE code challenge generation (SHA256 hash of verifier)
- Local callback server for authorization code (port 8090)
- Token exchange with client authentication
- MCP JSON-RPC requests with Bearer token
See the camp1-identity/demo-client directory on GitHub for the complete implementation with README.md and full source code.
Understanding the Two Paths¶
| Aspect | Option A: Device Code Flow | Option B: Authorization Code + PKCE Demo |
|---|---|---|
| Token visibility | You see and decode the JWT | Token displayed in terminal output |
| Learning value | High - understand JWT claims | High - see PRM discovery and production OAuth patterns |
| Setup complexity | Low - run script, copy token | Medium - generate secret, run demo |
| Ongoing friction | High - copy token every ~1 hour | Medium - demo restart after ~1 hour |
| Use in production | CLI tools, automation, headless environments | Browser-based apps, native clients |
| OAuth flow | Device Code Grant | Authorization Code Grant with PKCE |
| PRM demonstration | Manual configuration needed | Automatic discovery via PRM |
Key insight: Both methods result in the same JWT validation on the server. The server doesn't know (or care) which flow was used - it just validates the token.
Understanding JWT Validation¶
Regardless of which authentication path you chose, the secure server validates every request the same way:
auth = JWTVerifier(
jwks_uri=f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys",
audience=CLIENT_ID, # Checks 'aud' claim
issuer=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" # Checks 'iss' claim
)
What's checked:
Signature: Token cryptographically signed by Entra ID (not tampered)
Issuer (iss): Token from correct Entra ID tenant
Audience (aud): Token intended for THIS server (prevents confused deputy)
Expiration (exp): Token not expired
Not Before (nbf): Token is valid now (not used too early)
Decode your JWT at jwt.ms to see the claims!
Waypoint 6: Validate Security
Comprehensive Security Validation¶
Let's verify all security controls are properly configured:
This script performs comprehensive checks:
Expected output:
✅ Camp 1: Security Validation
==============================
📦 Loading azd environment...
🔍 Running security checks...
Check 1: Secrets in Key Vault
------------------------------
✅ Found 2 secrets in Key Vault
Name Enabled
-------------------------- ---------
demo-api-key True
external-service-secret True
Check 2: Managed Identity RBAC
-------------------------------
✅ Managed Identity has Key Vault Secrets User role
Role Scope
-------------------------- --------------------------------------------------
Key Vault Secrets User /subscriptions/.../resourceGroups/.../providers/...
Check 3: Container App Identity
--------------------------------
✅ Checking if container apps have managed identity assigned...
Name Identity
-------------------------- -----------
ca-sherpa-camp1-xxxxx UserAssigned
==============================
🎉 Security Validation Complete!
==============================
✅ Verified:
- Secrets stored in Key Vault (not env vars)
- Managed Identity has RBAC permissions
- Container Apps use Managed Identity
🔒 Security posture: SECURE
Ready for production!
Manual Verification Steps (Optional - Extra Credit)¶
Extra Credit - Not Required
The automated script above validates all the essential security controls. The steps below are optional and provide hands-on experience with testing authentication and authorization failures. Great for deeper learning, but feel free to skip ahead to the Security Checklist!
Verify Token Expiration
Try using an old/expired token:
# This should FAIL with "Token expired" or "Invalid token"
curl -X POST ${SECURE_URL}/mcp \
-H "Authorization: Bearer expired_or_old_token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Expected: 401 Unauthorized or similar error
Verify Audience Validation
Try using a token with wrong audience:
# Get a token for a different resource (e.g., Microsoft Graph)
WRONG_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv)
# This should FAIL because audience is wrong
curl -X POST ${SECURE_URL}/mcp \
-H "Authorization: Bearer $WRONG_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Expected: 401 Unauthorized - audience validation failed
Verify No Secrets in Environment Variables
- Open Azure Portal
- Navigate to your secure Container App
- Go to Settings → Environment variables
- Verify: No
REQUIRED_TOKENvariable! - Only configuration:
AZURE_CLIENT_ID,AZURE_TENANT_ID,KEY_VAULT_URL
Expected: No secret values visible, only configuration references
Security Checklist¶
Review what we've accomplished:
No hardcoded secrets in code
No secrets in environment variables (moved to Key Vault)
Managed Identity for Azure resource access (no passwords)
OAuth 2.1 authentication with Entra ID
JWT validation (signature, issuer, audience, expiration)
Least-privilege RBAC (Key Vault Secrets User only)
Audit logs enabled (Azure Monitor tracks all access)
Token expiration (tokens expire after ~1 hour)
Audience validation (prevents confused deputy attacks)
Compare: Before vs. After¶
| Security Control | Vulnerable Server | Secure Server |
|---|---|---|
| Authentication | Static token (camp1_demo_token_INSECURE) |
OAuth 2.1 JWT with Entra ID |
| Token Storage | Hardcoded in env var (visible in Portal) | Not applicable - JWT per request |
| Token Expiration | Never | ~1 hour |
| Token Revocation | Impossible | Possible via Entra ID |
| Token Tampering | Possible (plain string) | Cryptographically prevented (signed JWT) |
| Audience Validation | No - token works for any service | Yes - aud claim prevents confused deputy |
| User Context | Generic client_id only |
Rich claims (name, email, roles, tenant) |
| Token Rotation | Manual, risky | Automatic via token refresh |
| Client Discovery | Manual configuration | PRM (RFC 9728) enables zero-config |
| Azure Credentials | Connection strings in env vars | Managed Identity (passwordless) |
| Secrets Management | Environment variables | Azure Key Vault |
| RBAC | Not applicable | Least-privilege (Key Vault Secrets User) |
| Audit Logs | None | Azure Monitor tracks all access |
| Production Ready | ❌ Security vulnerabilities | ✅ Enterprise-grade security |
Summit View: What We Fixed¶
| Vulnerability | Solution | OWASP Risk Mitigated |
|---|---|---|
| Hardcoded tokens | OAuth 2.1 with Entra ID | MCP01, MCP07 |
| Tokens never expire | JWT with expiration (~1 hour) | MCP01 |
| Secrets in env vars | Azure Key Vault | MCP01 |
| No audience validation | JWTVerifier with aud check |
MCP07 |
| Password-based auth | Managed Identity | MCP01, MCP02 |
| Over-privileged access | Least-privilege RBAC | MCP02 |
Next Steps¶
Immediate Actions¶
- Review your own MCP servers for token exposure
- Migrate hardcoded secrets to Key Vault
- Implement OAuth 2.1 for production servers
- Apply least-privilege RBAC everywhere
Continue the Journey¶
Ready for the next challenge? Proceed to:
Camp 2: Gateway & Network Security →
Learn about:
- Gateway patterns for MCP
- Rate limiting and throttling
- Network security controls
- DDoS protection
- Traffic monitoring
Additional Resources¶
- Azure Managed Identity Documentation
- Azure Key Vault Best Practices
- OAuth 2.1 Specification
- OWASP MCP Azure Security Guide
- FastMCP Authentication Documentation
Troubleshooting¶
Issue: azd up fails with subscription access error
Solution: Ensure you're logged in with correct subscription:
Issue: Token acquisition fails
Solution: Ensure you're logged in with az login and have correct app registration:
Issue: Key Vault access denied
Solution: Verify Managed Identity has "Key Vault Secrets User" role:
Issue: JWT validation fails with 'Invalid audience'
Solution: Ensure AZURE_CLIENT_ID matches your Entra ID app:
Issue: Can't find deployed container app URL
Solution: Get deployment information:
