rgoussu@goussu: ~/library/go/go-security
~/library/go/go-security cat oauth2-and-oidc.md

OAuth2 & OIDC in Go

# The OAuth2/OIDC protocol family in Go with x/oauth2, go-oidc, JWT libraries, resource-server middleware and embeddable providers.

Conceptsaved 2026-08-09 #go#security#oauth2#oidc#jwt#middleware

Overview

Go's OAuth2 story is two small official-adjacent libraries plus composition: golang.org/x/oauth2 implements the client half of the flows, and coreos/go-oidc layers OIDC discovery and ID-token verification on top. There is no resource-server "starter" — you write (or import) a middleware that validates bearer JWTs against the provider's JWKS. The provider side is either delegated to an external IdP (Keycloak, or the Go-native Zitadel/Ory Hydra/Dex) or embedded via Ory Fosite. Protocol semantics live in Authentication & authorization; this note is the Go wiring.

Key points

  • golang.org/x/oauth2: oauth2.Config (client id/secret, endpoint, scopes, redirect), AuthCodeURL/Exchange for the auth-code flow, TokenSource for automatic refresh, Config.Client returning an *http.Client that injects and refreshes tokens.
  • clientcredentials.Config (x/oauth2/clientcredentials) is the service-to-service flow — machine identity, no user, the workhorse of internal APIs.
  • coreos/go-oidc (github.com/coreos/go-oidc/v3/oidc): oidc.NewProvider performs discovery from the issuer URL, caches the JWKS, and provider.Verifier validates ID tokens (signature, iss, aud, exp) — the correct default for verification.
  • JWT libraries: golang-jwt/jwt v5 (the maintained fork of dgrijalva/jwt-go) for general JWT work; lestrrat-go/jwx for full JOSE (JWKS, JWE, JWS). Both grew strict-by- default APIs after the algorithm-confusion era — always pin expected algorithms, never trust the token's alg header.
  • Resource-server middleware is DIY-but-small: a func(http.Handler) http.Handler that extracts the bearer token, verifies it, and stashes claims in the request context; gin/echo versions are the same logic behind their middleware signatures.
  • Flows that matter: authorization code + PKCE (oauth2.S256ChallengeOption and oauth2.VerifierOption are built in) for anything user-facing, client credentials for services. Implicit and password grants are dead (OAuth 2.1 removes them) — don't build them.
  • Providers in Go: delegate to Keycloak like everyone else, or stay in-ecosystem — Dex (federating OIDC proxy, CNCF), Zitadel (full IdP), Ory Hydra (headless OAuth2 server) with Ory Fosite as the embeddable SDK Hydra itself is built on.
  • Token propagation: pass the inbound bearer token (or exchange it, RFC 8693) on outgoing calls via oauth2.NewClient + a static/reuse TokenSource; inside a mesh, mTLS often carries workload identity instead.

Details

Client side: x/oauth2 in practice

oauth2.Config covers the redirect dance; the subtle part is token lifecycle. Config.TokenSource(ctx, tok) wraps a token with auto-refresh (ReuseTokenSource), and oauth2.NewClient builds an *http.Client whose transport injects Authorization: Bearer. Pitfalls worth remembering: the package silently ignores refresh failures until a request fails; the ctx passed at creation controls the refresh requests' HTTP client (via oauth2.HTTPClient context key), which matters for proxies and test doubles. Provider endpoint presets live in subpackages (x/oauth2/google, github, etc.), but for any OIDC-compliant IdP, go-oidc's discovered provider.Endpoint() is the better source.

Resource server: verification middleware

The canonical shape, framework-independent:

  1. Extract Authorization: Bearer (reject anything else).
  2. Verify with a cached verifier: go-oidc's Verifier for ID-token-shaped JWTs, or jwx's jwk.Cache + jwt.Parse with pinned algorithms for plain access tokens.
  3. Check audience/scope claims for the route; put the claims struct into context.

For gin the same function is wrapped as gin.HandlerFunc calling c.Abort() on failure; echo and chi are analogous. Multi-tenant APIs cache one verifier per issuer. If the IdP issues opaque tokens, verification becomes an introspection call (RFC 7662) — cache the result briefly.

Provider side: embed or delegate

Option What it is When
Keycloak External Java IdP, the default everywhere You just need an IdP; ops-friendly, huge feature set
Dex Go OIDC provider that federates upstream connectors (LDAP, GitHub, SAML) Kubernetes-adjacent, need OIDC in front of existing identity
Zitadel Go-native full IdP (multi-tenant, passkeys) Want a modern self-hosted IdP in the ecosystem
Ory Hydra Headless certified OAuth2/OIDC server; you supply login UI Need certified flows but own the UX
Ory Fosite Library: handlers for each grant, storage interfaces Embedding an authorization server inside your own binary

Embedding via Fosite is real work (storage, consent, key rotation are yours); default to delegating unless the product is identity.

Testing

  • Mock OIDC servers: oauth2-mock-style servers or the lightweight mockoidc package stand up a discovery endpoint + JWKS and mint arbitrary tokens — ideal for middleware unit tests.
  • Testcontainers: the Keycloak module for testcontainers-go runs the real IdP in integration tests; Dex is light enough to run as a plain container too.
  • Static keys: for pure JWT middleware tests, generate an ed25519/RSA key in the test, serve its JWKS from httptest.NewServer, sign tokens with jwx — no network, no mocks.

Examples

// Client credentials: service-to-service token source.
cc := clientcredentials.Config{
    ClientID:     os.Getenv("CLIENT_ID"),
    ClientSecret: os.Getenv("CLIENT_SECRET"),
    TokenURL:     "https://idp.example.com/oauth2/token",
    Scopes:       []string{"inventory:read"},
}
client := cc.Client(ctx) // *http.Client, auto-refreshing

// Resource server: go-oidc verification middleware.
provider, _ := oidc.NewProvider(ctx, "https://idp.example.com")
verifier := provider.Verifier(&oidc.Config{ClientID: "inventory-api"})

func auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
        if !ok {
            http.Error(w, "missing bearer token", http.StatusUnauthorized)
            return
        }
        idToken, err := verifier.Verify(r.Context(), raw)
        if err != nil {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r.WithContext(withClaims(r.Context(), idToken)))
    })
}

Related