Skip to content

Protect a Go net/http Service

The mcp-nethttp package provides Middleware — a standard func(http.Handler) http.Handler adapter. It extracts the bearer mandate from the Authorization header, verifies the ES256 signature against the zone JWKS, checks revocation, and enforces scope and agent requirements. Validated claims are stored in the request context and retrieved with ClaimsFromContext.

  • A Go HTTP service receiving requests from Caracal agents.
  • The STS JWKS endpoint reachable from the Go process.
Terminal window
go get github.com/garudex-labs/caracal/mcp-nethttp
go get github.com/garudex-labs/caracal/identity
package main
import (
"net/http"
"os"
mcpnethttp "github.com/garudex-labs/caracal/mcp-nethttp"
)
func main() {
opts := mcpnethttp.Options{
Issuer: os.Getenv("CARACAL_STS_URL"), // e.g. http://sts:8080
Audience: "resource://inventory",
ZoneID: os.Getenv("CARACAL_ZONE_ID"),
RequiredScopes: []string{"inventory:read"},
RequireAgent: true,
}
auth := mcpnethttp.Middleware(opts)
mux := http.NewServeMux()
mux.Handle("/inventory", auth(http.HandlerFunc(listInventory)))
mux.Handle("/inventory/", auth(http.HandlerFunc(getItem)))
http.ListenAndServe(":8080", mux)
}

ClaimsFromContext retrieves the validated identity.Claims from the request context. It returns (claims, true) on success and (zero, false) when no claims are present (caller not behind the middleware):

import (
"net/http"
mcpnethttp "github.com/garudex-labs/caracal/mcp-nethttp"
)
func listInventory(w http.ResponseWriter, r *http.Request) {
claims, ok := mcpnethttp.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// claims.Sub — principal subject
// claims.ZoneID — zone the mandate was issued in
// claims.Scope — space-separated scopes
// claims.Sid — session ID (for revocation lookup)
// claims.AgentSessionID — set when called from a spawned agent
// claims.HopCount — delegation depth
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"principal":"` + claims.Sub + `"}`))
}
FieldTypeRequiredDescription
IssuerstringYesSTS base URL; JWKS fetched from {Issuer}/.well-known/jwks.json
AudiencestringYesMust match the aud claim in the mandate
ZoneIDstringNoRejects mandates from a different zone
RequiredScopes[]stringNoAll listed scopes must be in the mandate
RequireAgentboolNoRejects mandates without agent_session_id
RequireDelegationboolNoRejects mandates without delegation_edge_id
RequireChainContains[]stringNoAll listed application IDs must appear in delegation_chain
MaxHopCountintNoRejects mandates with hop_count above this value
Revocationsrevocation.StoreNoChecked against the mandate’s sid claim

Apply different scopes per route by creating separate middleware instances:

readAuth := mcpnethttp.Middleware(mcpnethttp.Options{
Issuer: stsURL,
Audience: "resource://inventory",
RequiredScopes: []string{"inventory:read"},
})
writeAuth := mcpnethttp.Middleware(mcpnethttp.Options{
Issuer: stsURL,
Audience: "resource://inventory",
RequiredScopes: []string{"inventory:write"},
})
mux := http.NewServeMux()
mux.Handle("GET /inventory", readAuth(http.HandlerFunc(listInventory)))
mux.Handle("POST /inventory", writeAuth(http.HandlerFunc(createItem)))

Manual verification with the identity package

Section titled “Manual verification with the identity package”

For services that do not use net/http (e.g., gRPC interceptors or custom servers), use identity.Verify directly:

import "github.com/garudex-labs/caracal/identity"
func verifyMandate(tokenStr string) (identity.Claims, error) {
return identity.Verify(tokenStr, identity.Config{
Issuer: "http://sts:8080",
Audience: "resource://inventory",
ZoneID: "my-zone",
RequiredScopes: []string{"inventory:read"},
RequireAgent: true,
MaxHopCount: identity.DefaultMaxHopCount,
})
}

Error types returned by identity.Verify:

ErrorMeaning
identity.ErrTokenInvalidJWT signature invalid, expired, or malformed
identity.ErrZoneInvalidZone claim mismatch
identity.ErrAgentIdentityRequiredRequireAgent is true but agent_session_id absent
identity.ErrDelegationRequiredRequireDelegation is true but delegation_edge_id absent
identity.ErrHopCountExceededhop_count exceeds MaxHopCount
identity.ScopeMissingError{Scope: "…"}Mandate is missing the named scope
identity.ChainMismatchError{ApplicationID: "…"}Required application not in delegation chain
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"github.com/garudex-labs/caracal/identity"
mcpnethttp "github.com/garudex-labs/caracal/mcp-nethttp"
)
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
func listInventory(w http.ResponseWriter, r *http.Request) {
claims, ok := mcpnethttp.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !identity.HasScope(claims.Scope, "inventory:read") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
items := []Item{{ID: "1", Name: "Widget"}, {ID: "2", Name: "Gadget"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
func main() {
auth := mcpnethttp.Middleware(mcpnethttp.Options{
Issuer: os.Getenv("CARACAL_STS_URL"),
Audience: "resource://inventory",
ZoneID: os.Getenv("CARACAL_ZONE_ID"),
RequiredScopes: []string{"inventory:read"},
RequireAgent: true,
})
mux := http.NewServeMux()
mux.Handle("GET /inventory", auth(http.HandlerFunc(listInventory)))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}