Mount on your router
op.New returns a *op.Provider, and its ServeHTTP method makes it a standard http.Handler. The library does not own the listener and works with any router.
The provider is a sibling of your own handlers inside your router. Your middleware still wraps it, and every path it does not claim stays yours.
Mount on net/http, chi, or gin
mux := http.NewServeMux()
mux.Handle("/", provider)
http.ListenAndServe(":8080", mux)r := chi.NewRouter()
r.Mount("/oidc", provider) // the same path as WithMountPrefix (default "/oidc")
r.Handle("/.well-known/*", provider)
http.ListenAndServe(":8080", r)r := gin.New()
h := gin.WrapH(provider)
r.Any("/oidc/*path", h) // the same prefix as WithMountPrefix (default "/oidc")
r.Any("/.well-known/*path", h) // discovery and other well-known documents
http.ListenAndServe(":8080", r)Choose the URL prefix
The handler routes on full request paths, so the router must pass the path through unchanged and the library decides where its endpoints live. On net/http, register the handler at the root. On chi, Mount it at the same path you gave WithMountPrefix (default /oidc) and route /.well-known/* to it at the root, because the discovery documents sit outside the prefix. gin takes the same two subtrees as two wildcard routes, /oidc/*path and /.well-known/*path. A root catch-all such as r.Any("/*path", ...) would make the OP answer every path your own routes leave unclaimed, your 404s included. r.Mount("/", provider) on chi panics when the router already has a /* catch-all. Do not wrap the handler in http.StripPrefix: the stripped path no longer matches any endpoint. To move the endpoints, use WithMountPrefix:
op.New(
op.WithMountPrefix("/"), // default is "/oidc"
/* ... */
)With the default prefix /oidc, the endpoint /auth becomes /oidc/auth, /token becomes /oidc/token, and so on. If the issuer URL carries a path, that path comes first. The mount prefix applies to these protocol endpoints only; it never moves the discovery document.
| Issuer | Mount prefix | Token endpoint | Discovery document |
|---|---|---|---|
https://op.example.com | /oidc (default) | /oidc/token | /.well-known/openid-configuration |
https://op.example.com | / | /token | /.well-known/openid-configuration |
https://example.com/tenant | /oidc | /tenant/oidc/token | /.well-known/openid-configuration/tenant and /tenant/.well-known/openid-configuration |
An issuer with a path is discoverable at both the RFC 8414 location and the OpenID Connect Discovery 1.0 §4 location, so the provider serves both.
Custom endpoint paths
op.New(
op.WithEndpoints(op.Endpoints{
Authorize: "/oauth/authorize",
Token: "/oauth/token",
}),
/* ... */
)Empty fields keep the default, and discovery advertises every override. The defaults are /auth, /token, /userinfo, /end_session and /jwks, plus the endpoints that exist only when you enable their feature: /par, /introspect, /revoke, /register, /device_authorization, /bc-authorize and /grant_management. op.New rejects overrides that collide with each other.
Mount route by route
Routing / to the provider makes it answer every path your router has no handler for, so an unknown URL gets the OP's 404 rather than your application's. When the application already owns its router and its own 404 page, register the provider's patterns one by one instead. (*op.Provider).Routes() returns the http.ServeMux patterns the provider registered, in registration order, and the slice is a copy:
provider, err := op.New(
op.WithIssuer("https://app.example.com"),
op.WithMountPrefix("/identity"),
/* ... */
)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
for _, pattern := range provider.Routes() {
mux.Handle(pattern, provider)
}With WithMountPrefix("/identity") the endpoints sit under /identity (/identity/token, /identity/auth) and discovery stays at /.well-known/openid-configuration. Every other path, /no-such-page included, reaches your own handlers and your own 404.
The set of patterns follows the configuration. Optional endpoints, op.SPAUI, WithMountPrefix and WithGrantManagement add, move or drop entries, so build the loop from Routes() rather than writing the list by hand. Patterns use net/http syntax, and a pattern carries no HTTP method because the handlers check methods themselves. For another router, translate {name} (one path segment, for example /login/state/{uid}) to its single-segment parameter form and {path...} (the rest of the path, for example /login/assets/{path...}) to its catch-all form.
WithMountPrefix moves the protocol endpoints only. SPAUI.LoginMount is an absolute path: with LoginMount: "/login" the SPA entry is served at /login and its state endpoint at /login/state/{uid}, whatever the mount prefix is. If the OP lives under /identity and you want the login page there too, write LoginMount: "/identity/login".
examples/36-resource-server mounts an OP under /identity this way beside an API guarded by op/rs.
Compose with your own middleware
Middleware that observes requests can wrap the OP, as the figure above shows: access logs, tracing and metrics see every request. Middleware that changes security behaviour is different. The OP sets its own CORS answers, double-submit CSRF cookies, Content-Security-Policy, Referrer-Policy and session cookies, so apply your CORS, CSRF, security-header and session middleware to your own routes and leave the OP outside it. Attach them per route or per route group, never to the router as a whole:
mux := http.NewServeMux()
mux.Handle("/api/", withCORS(withCSRF(withSecurityHeaders(api)))) // your routes get the middleware
mux.Handle("/", provider) // the OP does not
http.ListenAndServe(":8080", accessLog(mux)) // observation only: wraps everythingTwo interactions show up in practice.
Client IP and WithTrustedProxies
The OP trusts X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host only when the TCP peer, r.RemoteAddr, is inside a CIDR you gave to op.WithTrustedProxies. chi's middleware.RealIP rewrites RemoteAddr from those headers for every caller, so behind it the OP no longer sees the proxy as its peer. It then ignores the forwarded scheme and host, and the client address it records for audit and risk decisions is whatever the header said, which a client that reaches the server directly can forge. Pick one: remove RealIP from the path to the OP and list the proxy ranges in op.WithTrustedProxies, or keep RealIP on your own route group only.
Referrer-Policy: no-referrer breaks the form posts
The OP's login and logout pages submit forms to themselves and need a same-origin Origin header. When a proxy, a security-header middleware or a <meta name="referrer"> in a template you embed sets Referrer-Policy: no-referrer on those pages, the browser sends Origin: null instead. The OP keeps its rule that anything outside the allow-list is refused: the SPA state route answers 403 and /end_session answers 400. Once per provider it also logs a WARN:
request rejected: the browser sent Origin: null on a same-origin form POST; a Referrer-Policy of no-referrer on the OP's pages (from a proxy, a security-header middleware, or a <meta name=referrer> in an embedder template) causes this — the OP's login and logout pages need same-originRemove the header for the OP's paths, or set same-origin (the value the OP's own pages use). fetch() in its default cors mode sends the real Origin whatever the policy is, so a SPA that talks to the state route with fetch() is not affected. Origin: null shows up for HTML form posts and for a fetch() made with mode: "same-origin".
What the OP does NOT mount
The OP keeps its footprint on your router small:
| The OP mounts | The OP does NOT mount |
|---|---|
| discovery (issuer-dependent path) | /metrics (you mount it with promhttp) |
/jwks | /healthz, /readyz |
/auth, /token, /userinfo | request-duration histogram middleware |
/end_session | OpenTelemetry HTTP server span middleware |
Optional: /par, /introspect, /revoke, /register, /device_authorization, /bc-authorize, /grant_management | a /debug/pprof mount |
/interaction/{uid} (login and consent screens), or with op.SPAUI the paths under its LoginMount | a generic per-IP rate limiter |
The OP emits business metrics and log and audit events through op.WithPrometheus, op.WithLogger and op.WithAuditLogger, into sinks you own. It creates no OpenTelemetry spans. HTTP-lifecycle observation belongs to you: wrap the router with otelhttp or promhttp.InstrumentHandler the way your SRE conventions require.
TLS / proxies
The OP expects to run behind a TLS-terminating ingress. Use op.WithTrustedProxies(cidrs ...) to allow-list the proxy ranges that supply X-Forwarded-For.
Without it the OP ignores X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host and uses the peer address for audit, risk, and rate decisions. The first request that carries one of those headers logs a single warning per provider on the configured logger.
If the proxy also terminates client TLS and forwards the certificate in a header for RFC 8705 certificate-bound access tokens, enable feature.MTLS and add op.WithMTLSProxy(op.MTLSProxy{HeaderName: headerName, TrustedProxies: cidrs}); op.New refuses op.WithMTLSProxy without feature.MTLS. An empty TrustedProxies falls back to the op.WithTrustedProxies ranges.
Shut down gracefully
/end_session answers the browser as soon as the OP session is gone, then delivers Back-Channel Logout tokens to the relying parties in the background. Nothing on the request path waits for that delivery, so a process that exits right after http.Server.Shutdown can drop queued logout notices. Call Provider.Shutdown after the listener has stopped accepting requests:
srv := &http.Server{Addr: ":8080", Handler: mux}
// ... on SIGTERM:
_ = srv.Shutdown(ctx) // stop accepting, finish in-flight requests
_ = provider.Shutdown(ctx) // wait for queued back-channel logout deliveryProvider.Shutdown returns nil when the queue drained and the context's error when it ran out of time. It is safe to call on a provider that never mounts /end_session. The upstream examples do this through examples/internal/serve.
Next
- Learn the concepts: new to OAuth and OIDC? A reading order from the three roles up to DPoP and FAPI, so each option you just set makes sense.
- Use cases: concrete setups for SPA login,
client_credentials, hot/cold storage, MFA and more. - Security: posture: the cookie, CSRF and SSRF defaults, and how to widen them when you need to.