MCP in Practice

Building an MCP Server with Auth0 Login in Go

Walk through building a minimal MCP server in Go that authenticates users via Auth0 using OAuth 2.0, JWT verification, and the MCP Go SDK.

10 min read

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:

  1. Discovery — The MCP client fetches /.well-known/oauth-protected-resource from your server. This endpoint returns a JSON document that tells the client where to authenticate (your Auth0 tenant URL) and what scopes are available.

  2. 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.

  3. Authenticated requests — The client attaches the JWT as a Bearer token in the Authorization header on every MCP request.

  4. 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:

FieldValue
NameMy MCP API
Identifier (Audience)http://127.0.0.1:8080/mcp
Signing AlgorithmRS256

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:

FieldValue
NameCursor MCP Client
Application TypeSingle Page Application
Allowed Callback URLscursor://anysphere.cursor-mcp/oauth/callback
Grant TypesAuthorization 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:

  1. It fetches /.well-known/oauth-protected-resource
  2. Opens a browser for Auth0 login
  3. Gets a JWT and sends it on every request
  4. You can now ask Cursor to use the whoami tool

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

LibraryRole
github.com/modelcontextprotocol/go-sdk/mcpMCP server, tools, HTTP transport
github.com/modelcontextprotocol/go-sdk/authRequireBearerToken middleware, ProtectedResourceMetadataHandler
github.com/modelcontextprotocol/go-sdk/oauthexProtectedResourceMetadata struct
github.com/lestrrat-go/jwx/v2/jwkJWKS fetching and caching
github.com/lestrrat-go/jwx/v2/jwtJWT 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