Skip to content

CORS ​

When a single-page app (SPA) in the browser calls Wicket's discovery document, JWKS, token, or UserInfo endpoints directly, you need cross-origin support. This page explains what Wicket v0.1.4 does and doesn't do here, and gives a verified host middleware implementation.

The short version ​

  • Wicket writes no Access-Control-* response headers, and doesn't handle OPTIONS preflight requests. Nothing in the code emits these headers.
  • Wicket does provide decision data: the list of cross-origin paths in the options, opts.Cors.CorsPaths, and a service, services.CorsPolicyService, that decides whether an origin is allowed based on clients' AllowedCorsOrigins. But no component inside Wicket calls them.
  • So cross-origin support has to be handled by the host itself at the outermost layer of the middleware chain, and it can reuse the two pieces of decision data above directly.

What in Wicket relates to CORS ​

CorsOptions ​

WicketOptions.Cors has three fields:

FieldDefaultActual effect in v0.1.4
CorsPathsSee belowJust a list of paths; Wicket doesn't read it internally
CorsPolicyName"wicket"Only checked for non-empty at startup; when empty, srv.UseWicket() panics. Plays no other role
PreflightCacheDurationnilNo code reads it

Default value of CorsPaths (from printing opts.Cors.CorsPaths):

text
/.well-known/openid-configuration
/.well-known/openid-configuration/jwks
/connect/token
/connect/userinfo
/connect/revocation

Of these, /connect/revocation exists in v0.1.4 only as a path constant; no token revocation endpoint is registered, and requests to it land in the host's own handler.

CorsPolicyService and Client.AllowedCorsOrigins ​

go
// package services
type CorsPolicyService interface {
	IsOriginAllowed(string) bool
}

The implementation registered in the container iterates over the AllowedCorsOrigins of all current clients and returns true if any client lists the origin. Things to note:

  • Comparison is an exact, character-by-character match: case, port, and trailing slash must all match the Origin header the browser sends. For example, write https://app.example.com, not https://app.example.com/.
  • Wildcards are not supported.
  • The decision is "global": as long as one client allows an origin, that origin is allowed on all CORS paths, regardless of which client is making the request (preflight requests don't carry a client identity anyway).
  • The service is registered as Transient and reads the current client list at resolution time, keeping a snapshot. If you provide a changing client list with cfg.SetClientsProvider, resolve the service on every request rather than resolving it once at startup and reusing it.

What happens when Wicket receives OPTIONS ​

Wicket's endpoint router matches by path only and ignores the method, so a preflight request that reaches Wicket is processed as a normal protocol request. In testing:

Preflight requestWicket's response
OPTIONS /connect/token400, with response body invalid_request
OPTIONS /.well-known/openid-configuration405 Method Not Allowed
OPTIONS /connect/userinfo405 Method Not Allowed

Neither response carries CORS headers, so the browser treats the preflight as failed. Preflight requests must therefore be answered by the host before they reach Wicket.

Host middleware ​

The following middleware handles only the paths in opts.Cors.CorsPaths, uses Wicket's CorsPolicyService to decide on the origin, and answers preflight requests itself. It has been compiled and run on top of the quick start example.

go
import (
	"net/http"
	"strings"

	"github.com/gonewx/godi"
	"github.com/gonewx/wicket/services"
)

// withCORS handles only wicket's cross-origin paths; all other requests pass through unchanged.
func withCORS(p godi.Provider, corsPaths []string, next http.Handler) http.Handler {
	paths := make(map[string]bool, len(corsPaths))
	for _, path := range corsPaths {
		paths[strings.ToLower(path)] = true
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		if origin == "" || !paths[strings.ToLower(r.URL.Path)] {
			next.ServeHTTP(w, r)
			return
		}

		w.Header().Add("Vary", "Origin")
		// Resolve per request: the policy service reads the current client list at resolution time
		allowed := godi.GetRequired[services.CorsPolicyService](p).IsOriginAllowed(origin)

		// Preflight request: finish here and don't pass it to wicket (wicket doesn't handle OPTIONS)
		if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
			if allowed {
				h := w.Header()
				h.Set("Access-Control-Allow-Origin", origin)
				h.Set("Access-Control-Allow-Methods", "GET, POST")
				h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
				h.Set("Access-Control-Max-Age", "600")
			}
			w.WriteHeader(http.StatusNoContent)
			return
		}

		if allowed {
			w.Header().Set("Access-Control-Allow-Origin", origin)
		}
		next.ServeHTTP(w, r)
	})
}

When wiring, place it outside hosting.ScopeMiddleware so that preflight requests never enter Wicket:

go
opts := godi.GetRequired[*options.WicketOptions](root)
// ... wire clients (including AllowedCorsOrigins), cfg.Freeze()

srv, err := server.DefaultServer(root, server.WithLogger(logger))
if err != nil {
	log.Fatal(err)
}
wicketHandler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))
handler := withCORS(root, opts.Cors.CorsPaths, wicketHandler)

Declare the allowed origins on the client:

go
client.AllowedCorsOrigins = []string{"https://app.example.com"}

Test results (origin https://app.example.com is declared on the client, https://evil.example.com is not):

RequestResult
Preflight OPTIONS /connect/token, declared origin204, with Access-Control-Allow-Origin: https://app.example.com plus methods, headers, and Max-Age
Preflight OPTIONS /connect/token, undeclared origin204, with no Access-Control-Allow-* headers; the browser blocks the follow-up request
GET discovery document, declared origin200, with Access-Control-Allow-Origin
GET discovery document, undeclared origin200, without Access-Control-Allow-Origin; the browser doesn't let scripts read the response
POST /connect/token, declared origin200, the token response carries Access-Control-Allow-Origin

A few design notes:

  • Don't set Access-Control-Allow-Credentials. These endpoints pass credentials through request parameters or the Authorization header and don't rely on cookies, so there's no need to allow cross-origin requests with cookies.
  • Always include Vary: Origin. The response differs by Origin; without it, an intermediate cache might return one origin's CORS headers to another origin.
  • Choose Max-Age yourself. Wicket doesn't use opts.Cors.PreflightCacheDuration; if you want to configure it centrally, read it in the middleware and convert it to seconds.
  • Allow only the paths you need. The authorize endpoint /connect/authorize and the end session endpoint are full-page browser navigations and don't need CORS; the introspection endpoint is called by backend APIs and shouldn't be exposed to browsers either. If you need extra paths, append them to the list passed to the middleware; there's no need to modify opts.Cors.CorsPaths.

Clients in the browser

An SPA running in the browser can't keep a client secret. When an SPA calls the token endpoint directly, use authorization code + PKCE and configure that client not to require a secret (a public client). Don't put a machine client's secret into frontend code just to get past CORS.

If you don't want to reuse Wicket's decision logic ​

If your origin allowlist has nothing to do with client configuration (for example, it is managed centrally by a gateway), replace CorsPolicyService in the middleware with your own set lookup and keep the rest of the logic. You can also use any mature third-party CORS middleware, as long as it meets two conditions: it covers the Wicket paths listed above, and it wraps the Wicket middleware from outside, so that preflight requests end before they reach Wicket.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors