The Model Context Protocol (MCP) lets AI assistants like Cursor and Claude call tools on remote servers. When those tools access private data, you need authentication. This article walks through building a minimal MCP server in Go that authenticates users via Auth0.
By the end, you’ll have a working server with a single authenticated tool — and understand every moving part well enough to extend it.
How the Authentication Flow Works
MCP authentication follows the OAuth 2.0 standard. Three parties are involved: the MCP client (Cursor, Claude), your MCP server, and Auth0.
User MCP Client (Cursor) MCP Server Auth0
| | | |
| "run a tool" | | |
|----------------->| | |
| | GET /.well-known/ | |
| | oauth-protected- | |
| | resource | |
| |----------------------->| |
| | {auth_servers:[...]} | |
| |<-----------------------| |
| | | |
| Login (browser) | Redirect to Auth0 | |
|<-----------------|-------------------------------------------> |
| Credentials | | |
|----------------------------------------------------------> |
| JWT | | |
|<--------------------|<----------------------------------------| |
| | | |
| | POST /mcp | |
| | Authorization: Bearer | |
| | <JWT> | |
| |----------------------->| |
| | | Verify JWT (JWKS) |
| | |------------------------->|
| | | Public keys |
| | |<-------------------------|
| | | |
| | Tool result | |
| |<-----------------------| |
| Response | | |
|<-----------------| | |
The flow has four stages:
-
Discovery — The MCP client fetches
/.well-known/oauth-protected-resourcefrom your server. This endpoint returns a JSON document that tells the client where to authenticate (your Auth0 tenant URL) and what scopes are available. -
Login — The client opens a browser window pointing to Auth0. The user logs in (email/password, SSO, social login — whatever you’ve configured). Auth0 returns a signed JWT.
-
Authenticated requests — The client attaches the JWT as a
Bearertoken in theAuthorizationheader on every MCP request. -
Verification — Your server validates the JWT by fetching Auth0’s public keys (JWKS), checking the signature, audience, and expiration. If valid, the request proceeds to the tool handler.
Prerequisites
- Go 1.22+ installed
- An Auth0 account (sign up free)
- Basic familiarity with Go and HTTP servers
Step 1: Configure Auth0
You need to create two resources in the Auth0 Dashboard.
Create an API (Resource Server)
Go to Applications > APIs > Create API:
| Field | Value |
|---|---|
| Name | My MCP API |
| Identifier (Audience) | http://127.0.0.1:8080/mcp |
| Signing Algorithm | RS256 |
The identifier is the audience claim your server will check in every JWT. For local development, using http://127.0.0.1:8080/mcp works well. In production, use your public URL.
Optionally, add a permission/scope called read:all under the Permissions tab.
Create a Single Page Application (SPA)
Go to Applications > Applications > Create Application:
| Field | Value |
|---|---|
| Name | Cursor MCP Client |
| Application Type | Single Page Application |
| Allowed Callback URLs | cursor://anysphere.cursor-mcp/oauth/callback |
| Grant Types | Authorization Code, Refresh Token |
MCP clients like Cursor and Claude are public clients — they don’t hold a client secret. That’s why we use the SPA application type.
For Claude, add these callback URLs instead:
https://claude.ai/api/mcp/auth_callback,
https://claude.com/api/mcp/auth_callback
Authorize the Client
Go to Applications > APIs > My MCP API > Machine to Machine Applications, find your SPA client, toggle it on, and grant the scopes you defined.
Take note of:
- Auth0 Domain:
https://your-tenant.auth0.com - API Identifier:
http://127.0.0.1:8080/mcp
Step 2: Create the Project
mkdir my-mcp-auth && cd my-mcp-auth
go mod init my-mcp-auth
You’ll need two dependencies:
- MCP Go SDK — handles the MCP protocol, HTTP transport, and auth middleware
- jwx — JWT parsing and JWKS key management
go get github.com/modelcontextprotocol/go-sdk@latest
go get github.com/lestrrat-go/jwx/v2@latest
Step 3: Write the Server
Create main.go. The entire server fits in about 120 lines.
package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/oauthex"
)
const (
authServer = "https://YOUR-TENANT.auth0.com" // Replace with your Auth0 domain
audience = "http://127.0.0.1:8080/mcp" // Must match your Auth0 API Identifier
listenAddr = "127.0.0.1:8080"
)
func main() {
jwksCache := buildJWKSCache()
verifyToken := buildTokenVerifier(jwksCache)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
mux.Handle("/mcp", authenticatedMCPHandler(verifyToken))
mux.Handle("/.well-known/oauth-protected-resource", prmHandler())
log.Printf("MCP server with Auth0 auth listening on http://%s/mcp", listenAddr)
srv := &http.Server{
Addr: listenAddr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
JWKS Cache
Auth0 signs JWTs with private keys and publishes the corresponding public keys at a well-known URL. The JWKS cache fetches these keys and refreshes them periodically, so your server can verify JWT signatures without calling Auth0 on every request.
func buildJWKSCache() *jwk.Cache {
cache := jwk.NewCache(context.Background())
jwksURL := strings.TrimSuffix(authServer, "/") + "/.well-known/jwks.json"
if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
log.Fatalf("Failed to register JWKS endpoint: %v", err)
}
return cache
}
Token Verifier
This function is called by the MCP SDK’s auth middleware on every request. It receives the raw JWT string and must return a TokenInfo struct or an error.
The verification is a two-step process: first parse without verification to extract the issuer, then fully verify the signature and claims using the cached JWKS keys.
func buildTokenVerifier(jwksCache *jwk.Cache) auth.VerifyFunc {
jwksURL := strings.TrimSuffix(authServer, "/") + "/.well-known/jwks.json"
return func(ctx context.Context, tokenString string, _ *http.Request) (*auth.TokenInfo, error) {
// Parse without verification to check the issuer.
unverified, err := jwt.ParseString(tokenString,
jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return nil, fmt.Errorf("malformed token: %w", err)
}
issuer := strings.TrimSuffix(unverified.Issuer(), "/")
expected := strings.TrimSuffix(authServer, "/")
if issuer != expected {
return nil, fmt.Errorf("unexpected issuer: %s", issuer)
}
// Verify signature, audience, and expiration using JWKS.
keySet, err := jwksCache.Get(ctx, jwksURL)
if err != nil {
return nil, fmt.Errorf("JWKS fetch failed: %w", err)
}
token, err := jwt.ParseString(tokenString,
jwt.WithKeySet(keySet),
jwt.WithValidate(true),
jwt.WithAudience(audience),
)
if err != nil {
return nil, fmt.Errorf("verification failed: %w", err)
}
userID := token.Subject()
if userID == "" {
userID = "_anonymous"
}
return &auth.TokenInfo{
UserID: userID,
Expiration: token.Expiration(),
Extra: map[string]any{"raw_token": tokenString},
}, nil
}
}
Protected Resource Metadata (PRM)
This is the discovery endpoint. When an MCP client connects, the first thing it does is fetch this endpoint to learn where to send the user for authentication.
func prmHandler() http.Handler {
return auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: fmt.Sprintf("http://%s/mcp", listenAddr),
AuthorizationServers: []string{authServer + "/"},
ScopesSupported: []string{"openid", "profile", "read:all"},
})
}
The response looks like:
{
"resource": "http://127.0.0.1:8080/mcp",
"authorization_servers": ["https://your-tenant.auth0.com/"],
"scopes_supported": ["openid", "profile", "read:all"]
}
MCP Handler with Auth Middleware
The MCP SDK provides auth.RequireBearerToken middleware that intercepts requests, extracts the Authorization: Bearer <token> header, and calls your verify function. If verification fails, it returns a 401 with a WWW-Authenticate header pointing to the PRM endpoint.
func authenticatedMCPHandler(verifyToken auth.VerifyFunc) http.Handler {
createServer := func(_ *http.Request) *mcp.Server {
server := mcp.NewServer(&mcp.Implementation{
Name: "my-mcp-auth-server",
Version: "0.1.0",
}, nil)
registerTools(server)
return server
}
handler := mcp.NewStreamableHTTPHandler(createServer, &mcp.StreamableHTTPOptions{
Stateless: true,
})
prmURL := fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", listenAddr)
authMiddleware := auth.RequireBearerToken(verifyToken, &auth.RequireBearerTokenOptions{
ResourceMetadataURL: prmURL,
})
return authMiddleware(handler)
}
Tools
Tools are where the actual functionality lives. The whoami tool reads the authenticated user’s ID from the token info that the middleware attached to the request.
func registerTools(server *mcp.Server) {
type WhoAmIArgs struct{}
mcp.AddTool(server, &mcp.Tool{
Name: "whoami",
Description: "Returns the authenticated user's ID from the JWT token",
}, func(_ context.Context, req *mcp.CallToolRequest, _ WhoAmIArgs) (*mcp.CallToolResult, any, error) {
userID := "_unknown"
if req.Extra.TokenInfo != nil {
userID = req.Extra.TokenInfo.UserID
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("Authenticated as: %s", userID)},
},
}, nil, nil
})
}
func healthHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}
Step 4: Run It
go mod tidy
go run main.go
Output:
MCP server with Auth0 auth listening on http://127.0.0.1:8080/mcp
Verify the PRM endpoint
curl -s http://127.0.0.1:8080/.well-known/oauth-protected-resource | jq
{
"resource": "http://127.0.0.1:8080/mcp",
"authorization_servers": [
"https://your-tenant.auth0.com/"
],
"scopes_supported": [
"openid",
"profile",
"read:all"
]
}
Verify auth is enforced
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
You should get a 401 Unauthorized response with a WWW-Authenticate header — that means auth is working.
Step 5: Connect from Cursor
Add to your MCP settings file (~/.cursor/mcp.json):
{
"mcpServers": {
"my-auth-mcp": {
"url": "http://127.0.0.1:8080/mcp"
}
}
}
When Cursor connects:
- It fetches
/.well-known/oauth-protected-resource - Opens a browser for Auth0 login
- Gets a JWT and sends it on every request
- You can now ask Cursor to use the
whoamitool
What’s Happening Under the Hood
Here’s the component breakdown:
┌─────────────────────────────────────────────────────────┐
│ HTTP Request │
│ Authorization: Bearer <JWT> │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ auth.RequireBearerToken middleware │
│ │
│ • Extracts Bearer token from Authorization header │
│ • Calls verifyToken() │
│ • On failure: returns 401 + WWW-Authenticate │
│ • On success: attaches TokenInfo to request context │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ verifyToken function │
│ │
│ 1. Parse JWT (unverified) → extract issuer │
│ 2. Check issuer is our Auth0 tenant │
│ 3. Fetch JWKS public keys (cached, refreshed ~15 min) │
│ 4. Verify: signature + audience + expiration │
│ 5. Return TokenInfo {UserID, Expiration, Extra} │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ MCP StreamableHTTP Handler │
│ │
│ • Routes JSON-RPC methods (initialize, tools/call, ...) │
│ • Dispatches to registered tool handlers │
│ • Tool accesses user via req.Extra.TokenInfo │
└──────────────────────────────────────────────────────────┘
Key Libraries
| Library | Role |
|---|---|
github.com/modelcontextprotocol/go-sdk/mcp | MCP server, tools, HTTP transport |
github.com/modelcontextprotocol/go-sdk/auth | RequireBearerToken middleware, ProtectedResourceMetadataHandler |
github.com/modelcontextprotocol/go-sdk/oauthex | ProtectedResourceMetadata struct |
github.com/lestrrat-go/jwx/v2/jwk | JWKS fetching and caching |
github.com/lestrrat-go/jwx/v2/jwt | JWT parsing, signature verification, claim validation |
Going Further
This minimal server covers the core pattern. Here’s how to extend it:
Add more tools — Follow the same pattern: define an args struct, register with mcp.AddTool, and access req.Extra.TokenInfo for the authenticated user.
Call external APIs with the user’s token — The raw JWT is stored in req.Extra.TokenInfo.Extra["raw_token"]. Use it to call APIs that accept the same JWT, or implement RFC 8693 Token Exchange to swap it for a different API’s token.
Support multiple Auth0 tenants — Register multiple JWKS URLs in the cache and check the issuer against a list of allowed servers.
Move to environment variables — Replace the hardcoded constants with env vars or a config library like koanf for production deployments.
Add scopes checking — The TokenInfo.Scopes field is populated during verification. Check it in tool handlers to enforce fine-grained permissions.
Full Source Code
The complete main.go file is approximately 120 lines. The full source for a production-grade implementation with token exchange, multiple tools, and Helm charts is available in the LFX MCP Server repository.
Sources
- MCP Specification — Authorization (2025-11-25) — Official MCP spec defining OAuth 2.1 resource server authorization and RFC 9728 protected resource metadata discovery.
- RFC 9728 — OAuth 2.0 Protected Resource Metadata — The IETF standard behind the
/.well-known/oauth-protected-resourcediscovery endpoint used by MCP servers. - MCP Go SDK (
modelcontextprotocol/go-sdk) — Go SDK providingmcp.Server,auth.RequireBearerTokenmiddleware, andProtectedResourceMetadataHandler. - lestrrat-go/jwx v2 — Go library for JWx (JWA/JWE/JWK/JWS/JWT), including
jwk.Cachefor JWKS auto-refresh andjwt.Parsefor signature verification. - Auth0 — Navigating RS256 and JWKS — Auth0’s guide on RS256 signing and JWKS-based JWT verification.
- Auth0 — API Settings — Documentation for configuring API identifiers (audience), signing algorithms, and scopes in Auth0.
- Cursor — Model Context Protocol (MCP) Docs — Cursor’s official documentation on connecting to MCP servers with OAuth support.
- Claude.ai — Building Custom Connectors — Anthropic’s documentation on MCP OAuth integration and callback URLs for Claude.