---
title: "Build an MCP authorization server in Go"
description: "Run an OAuth 2.1 authorization server and a protected MCP route in one Go program with theauth-go: discovery, registration, PKCE, sign-in and token checks."
canonical: https://theauth.dev/guides/mcp-authorization-server-go/
lastmod: 2026-10-08
---

Guide

# Build an MCP authorization server in Go

One Go program that issues OAuth 2.1 tokens, signs a person in, and protects an MCP route by validating those tokens. You will then walk the flow with curl so every step is visible.

Run end to end on 2026-10-08 with theauth-go v2.7.1, mcpresource v0.1.0, chi v5 and Go 1.26.

## What you will build

- An authorization server mounted at /oauth/* and /.well-known/*, with a gated dynamic client registration endpoint.
- A protected /mcp route that validates the JWTs the server issues, using the mcpresource module.
- A curl walkthrough: discovery, register, sign in, authorize with PKCE, exchange the code, call the route.

## Before you start

- Go 1.25 or newer, curl and openssl.
- Memory storage keeps this to one file. The OAuth server needs memory, Postgres or MySQL: SQLite does not support it.

## Create the module

terminal

```text
mkdir mcp-as && cd mcp-as
go mod init example.com/mcp-as
```

Use the /v2 import path. The path without it is frozen at v1.0.0.

## Write main.go

main.go

```go
package main

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/glincker/theauth-go/mcpresource"
	"github.com/glincker/theauth-go/v2"
	"github.com/glincker/theauth-go/v2/storage/memory"
	"github.com/go-chi/chi/v5"
)

const (
	baseURL  = "http://localhost:8080"
	resource = baseURL + "/mcp"
)

// logSender prints each email instead of sending it, so you can copy the
// magic link out of the terminal.
type logSender struct{}

func (logSender) Send(_ context.Context, to, subject, body string) error {
	fmt.Printf("\n--- email to %s: %s ---\n%s\n\n", to, subject, body)
	return nil
}

func main() {
	key := make([]byte, 32)
	if _, err := rand.Read(key); err != nil {
		log.Fatal(err)
	}

	a, err := theauth.New(theauth.Config{
		Storage:       memory.New(), // swap for storage/sqlite or Postgres to keep data
		BaseURL:       baseURL,
		EmailSender:   logSender{},
		EncryptionKey: key,
		AuthorizationServer: &theauth.AuthorizationServerConfig{
			Issuer: baseURL,
			Resources: []theauth.ProtectedResource{
				{Identifier: resource, Scopes: []string{"tools.read"}},
			},
			// Dynamic client registration is closed unless you open it. This token is
			// the "initial access token" an MCP client presents to register itself.
			RegistrationTokens: []string{"dev-registration-token"},
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer a.Close()

	r := chi.NewRouter()
	a.Mount(r) // /auth/*, /oauth/*, /.well-known/*

	// The MCP resource server: validates JWTs issued by the server above.
	v := mcpresource.New(resource, mcpresource.WithJWKS(baseURL+"/oauth/jwks"))
	r.Route("/mcp", func(r chi.Router) {
		r.Use(v.Middleware)
		r.Get("/", func(w http.ResponseWriter, req *http.Request) {
			p, _ := v.Principal(req.Context())
			_ = json.NewEncoder(w).Encode(map[string]any{"subject": p.Subject, "scope": p.Scope})
		})
	})

	log.Println("listening on", baseURL)
	log.Fatal(http.ListenAndServe(":8080", r))
}
```

terminal

```go
go mod tidy
go run .
```

The server logs two warnings about secure cookies and trusted proxies. They are expected on plain HTTP in development and have config switches for when you deploy.

What the config does: AuthorizationServer.Resources lists the protected resources and their scopes, and every token request must name one. RegistrationTokens gates dynamic client registration behind a bearer token, since open registration is off by default. mcpresource.New builds a validator that fetches the server's keys from /oauth/jwks on first use.

## Look at the discovery documents

terminal

```sh
B=http://localhost:8080
R=$B/mcp
CB=http://localhost:9999/cb
curl -s $B/.well-known/oauth-authorization-server
curl -s $B/.well-known/oauth-protected-resource
curl -si $R | head -5
```

The first lists the endpoints and S256 as the only PKCE method. The last call returns 401 with a WWW-Authenticate header whose resource_metadata points at the protected resource document.

## Register a client

terminal

```text
CID=$(curl -s $B/oauth/register -H 'content-type: application/json' \
  -H 'authorization: Bearer dev-registration-token' \
  -d '{"client_name":"demo","redirect_uris":["'$CB'"],"grant_types":["authorization_code"],"response_types":["code"],"token_endpoint_auth_method":"none"}' \
  | sed -E 's/.*"client_id":"([^"]+)".*/\1/')
echo $CID
```

You get a client id such as client-01M4E3V4.... Without the bearer token the endpoint refuses the request.

## Sign in as a person

terminal

```sh
curl -s -c jar $B/auth/magic-link -H 'content-type: application/json' -d '{"email":"ada@example.com"}'
```

The server terminal prints the email with a link ending in /auth/magic-link/verify?token=.... Open it with the cookie jar:

terminal

```sh
curl -s -b jar -c jar "PASTE_THE_LINK"
curl -s -b jar $B/auth/me
```

The first call returns {"ok":true} and stores the session cookie. The second returns the user.

## Authorize with PKCE

Make a one-time verifier and its S256 challenge, then ask for a code. The signed-in cookie stands in for the consent screen, and the answer is a 302 to your redirect URI.

terminal

```text
VER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CH=$(printf %s "$VER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
LOC=$(curl -si -b jar -G "$B/oauth/authorize" \
  --data-urlencode response_type=code --data-urlencode client_id=$CID \
  --data-urlencode redirect_uri=$CB --data-urlencode scope=tools.read \
  --data-urlencode "resource=$R" --data-urlencode code_challenge=$CH \
  --data-urlencode code_challenge_method=S256 --data-urlencode state=xyz \
  | sed -n 's/^[Ll]ocation: //p' | tr -d '\r')
CODE=$(echo "$LOC" | sed -E 's/.*code=([^&]+).*/\1/')
echo $LOC
```

The location looks like http://localhost:9999/cb?code=...&state=xyz. Nothing listens on port 9999, and nothing needs to.

## Exchange the code and call the route

terminal

```sh
RESP=$(curl -s $B/oauth/token -d grant_type=authorization_code -d code=$CODE \
  -d redirect_uri=$CB -d client_id=$CID -d code_verifier=$VER \
  --data-urlencode "resource=$R")
TOK=$(echo $RESP | sed -E 's/.*"access_token":"([^"]+)".*/\1/')
curl -s $R -H "authorization: Bearer $TOK"
```

The token endpoint verifies the verifier against the stored challenge. The route then answers {"scope":["tools.read"],"subject":"01M4..."}, where the subject is the person who signed in. Try the same call with a changed character in the token and you get 401.

## Before you ship this

- **Swap the storage.** memory.New() forgets everything on restart. Use the Postgres or MySQL backend for the OAuth server and keep EncryptionKey in a secret store, not generated per run.
- **Use HTTPS issuer and base URLs.** The issuer is the iss claim in every token.
- **Treat the registration token as a credential.** Load it from the environment, and look at AllowAnonymousRegistration only if you really want open registration.
- **Send real email.** Replace logSender with an implementation of the email.Sender interface.
- **Turn on the hardening you need.** DPoP, PAR, JAR and CIBA are separate config fields. The [Go overview](https://theauth.dev/go/) lists what each gives you.
- **Set TrustedProxies** when you run behind a reverse proxy, or every client shares one rate-limit bucket.

## Questions

**Why a separate mcpresource module?**

It has no dependencies and validates tokens inside an MCP server, so your resource server does not pull in the whole authorization library. Here both run in one process for convenience.

**Can the MCP server and the authorization server be separate services?**

Yes. Point mcpresource.WithJWKS at the authorization server's JWKS URL, or use token introspection instead.

**Does it support CIMD?**

Yes, through AuthorizationServerConfig.CIMD. It is off by default and fail-closed: you choose which hosts the server may fetch client metadata from.

## Keep going

[theauth-go overview](https://theauth.dev/go/) [MCP authorization docs](https://docs.theauth.dev/go/concepts/mcp-authorization) [The same flow in TypeScript](https://theauth.dev/guides/mcp-server-typescript/) [examples/mcp-server](https://github.com/glincker/theauth-go/tree/main/examples/mcp-server)
