Skip to content

Quick start ​

Starting from an empty directory, you'll write a Go program of roughly 100 lines that mounts Wicket on the standard library's net/http, registers a backend-service client, gets a JWT access token with curl, and confirms it through the introspection endpoint.

The example has been run end to end against Wicket v0.1.4; its source lives in the repository under examples/quickstart/.

Prerequisites ​

You needNotes
Go 1.27Wicket's go.mod declares go 1.27; older toolchains can't resolve it
curlFor calling the endpoints by hand
bash
mkdir wicket-quickstart && cd wicket-quickstart
go mod init example.com/quickstart
go get github.com/gonewx/wicket@v0.1.4

About go.work

If your machine has a global go.work, run the commands with GOWORK=off so the workspace doesn't resolve Wicket to a local directory.

The full program ​

Save the following as main.go, then run go mod tidy.

go
package main

import (
	"crypto/rand"
	"crypto/rsa"
	"log"
	"log/slog"
	"net/http"
	"os"

	"github.com/gonewx/godi"
	wicket "github.com/gonewx/wicket"
	"github.com/gonewx/wicket/config/options"
	"github.com/gonewx/wicket/di"
	"github.com/gonewx/wicket/hosting"
	"github.com/gonewx/wicket/server"
	wicketmodels "github.com/gonewx/wicket/models"
	"github.com/gonewx/wicket/storage"
	"github.com/gonewx/wicket/storage/memory"
	"github.com/gonewx/wicket/storage/models"
	"github.com/gonewx/wicket/tokens"
	"github.com/gonewx/wicket/utils"
	"github.com/lestrrat-go/jwx/v3/jwk"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

	// 1. Composition root: wires every Wicket service
	root, err := di.NewRoot()
	if err != nil {
		log.Fatal(err)
	}
	defer root.Close()

	// 2. Protocol options: the issuer identifier
	opts := godi.GetRequired[*options.WicketOptions](root)
	opts.IssuerUri = "http://localhost:5000"

	// 3. Register resources and clients
	api := models.NewApiScope()
	api.Name = "orders.read"

	apiSecret := models.NewSecret()
	apiSecret.Value = utils.Sha256("api-secret")
	orders := models.NewApiResource()
	orders.Name = "orders"
	orders.Scopes = []string{"orders.read"}
	orders.ApiSecrets = []*models.Secret{apiSecret}

	secret := models.NewSecret()
	secret.Value = utils.Sha256("change-me")

	client := models.NewClient()
	client.ClientId = "billing-worker"
	client.ClientSecrets = []*models.Secret{secret}
	client.AllowedScopes = []string{"orders.read"}
	if err := client.SetAllowedGrantTypes(wicket.GrantTypesClientCredentials); err != nil {
		log.Fatal(err)
	}

	cfg := root.ServerConfig()
	signing, validation := devSigningKey()
	cfg.SetSigningCredentialStores(signing)
	cfg.SetValidationKeysStores(validation)
	cfg.SetApiScopes([]*models.ApiScope{api})
	cfg.SetApiResources([]*models.ApiResource{orders})
	cfg.SetClients([]*models.Client{client})
	cfg.AllowInMemoryFallback() // local development only
	cfg.Freeze()

	// 4. Mount the middleware
	srv, err := server.DefaultServer(root, server.WithLogger(logger))
	if err != nil {
		log.Fatal(err)
	}
	app := http.NewServeMux()
	app.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("host app\n"))
	})
	handler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))

	log.Println("listening on :5000")
	log.Fatal(http.ListenAndServe(":5000", handler))
}

// devSigningKey creates an in-process RSA signing key (development only: a restart means a new key).
func devSigningKey() ([]storage.SigningCredentialStore, []storage.ValidationKeysStore) {
	raw, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		log.Fatal(err)
	}
	// Use the RFC 7638 thumbprint as kid so it matches the kid published in the JWKS
	key, err := jwk.Import(raw)
	if err != nil {
		log.Fatal(err)
	}
	if err := jwk.AssignKeyID(key); err != nil {
		log.Fatal(err)
	}
	kid, _ := key.KeyID()

	cred, err := tokens.NewSigningCredentials(raw, tokens.WithAlgorithm("RS256"), tokens.WithKid(kid))
	if err != nil {
		log.Fatal(err)
	}
	info := &wicketmodels.SecurityKeyInfo{Key: raw.Public(), SigningAlgorithm: cred.Algorithm}
	return []storage.SigningCredentialStore{memory.NewMemorySigningCredentialStore(cred)},
		[]storage.ValidationKeysStore{memory.NewMemoryValidationKeysStore([]*wicketmodels.SecurityKeyInfo{info})}
}

Step by step ​

1. Composition root ​

go
root, err := di.NewRoot()

di.NewRoot() builds Wicket's composition root, where every endpoint handler, validator and token service is wired. It returns (*Root, error); call root.Close() when you're done to release what it holds (for example, stores backed by injected database connections).

2. Protocol options ​

go
opts := godi.GetRequired[*options.WicketOptions](root)
opts.IssuerUri = "http://localhost:5000"

WicketOptions is the main protocol configuration; change it after NewRoot() and before UseWicket(). IssuerUri becomes the iss claim of every token and the prefix of every endpoint URL in the discovery document; in production it must be a publicly reachable HTTPS address.

Automatic key management is off by default, so the example supplies an in-process RSA signing key (see devSigningKey) through ServerConfig. That's the easiest setup to follow, but every restart produces a new key, and tokens issued before it can no longer be validated. See Signing keys for the production approach.

3. Resources and clients ​

The example registers three things:

  • API scope orders.read: the unit of permission a client can request.
  • API resource orders: a protected backend that owns the orders.read scope. Tokens carry orders in their aud claim; the resource's own secret, api-secret, is used to call the introspection endpoint.
  • Client billing-worker: allowed only the client_credentials grant and only the orders.read scope.

Client secrets are stored as SHA-256 hashes (utils.Sha256("change-me")); the plain text only exists on the caller's side.

4. ServerConfig and freezing ​

go
cfg := root.ServerConfig()
cfg.SetClients(...)
cfg.AllowInMemoryFallback() // local development only
cfg.Freeze()

ServerConfig is the single entry point for the host to inject data and implementations into Wicket: clients, resources, signing keys and every store come in here.

  • AllowInMemoryFallback() explicitly allows stores you didn't inject to fall back to in-memory implementations. Without it, Freeze() panics if even one store is missing — on purpose, so production never quietly runs on memory.
  • Freeze() validates the assembly once (for example, the store check above). It doesn't lock the configuration — setters called afterward still take effect — so do all injection before Freeze().

5. Mount the middleware ​

go
handler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))

Both layers are required:

  1. hosting.ScopeMiddleware(root) creates a dependency-injection scope for each request;
  2. srv.UseWicket() intercepts protocol paths (/connect/*, /.well-known/*) and hands every other request to your app unchanged.

Run and verify ​

bash
go run .

In another terminal:

Discovery document — confirm the server is up:

bash
curl -s http://localhost:5000/.well-known/openid-configuration

Issue a token — client credentials over HTTP Basic authentication:

bash
curl -s -u billing-worker:change-me \
  -d grant_type=client_credentials \
  -d scope=orders.read \
  http://localhost:5000/connect/token
json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 3600,
  "scope": "orders.read",
  "token_type": "Bearer"
}

Base64url-decode the middle segment of access_token to see its claims:

json
{
  "aud": ["orders"],
  "client_id": "billing-worker",
  "iss": "http://localhost:5000",
  "scope": ["orders.read"],
  "iat": 1794538592,
  "nbf": 1794538592,
  "exp": 1794542192
}

Introspect the token — calling as the orders API resource:

bash
curl -s -u orders:api-secret \
  -d token=<access_token from the previous step> \
  http://localhost:5000/connect/introspect
json
{ "active": true, "aud": ["orders"], "client_id": "billing-worker", "scope": "orders.read", "...": "..." }

Your own routes are untouched:

bash
curl -s http://localhost:5000/
# host app

Troubleshooting ​

SymptomCause
The token endpoint returns no signing credential availableNo usable signing key: inject signing credentials or configure key management correctly
{"error":"invalid_client"}Wrong client ID or secret, or the secret wasn't stored hashed with utils.Sha256
{"error":"invalid_scope"}The requested scope isn't in the client's AllowedScopes, or no matching ApiScope is registered
Freeze() panics: store seam "..." not injected by host and in-memory fallback not declaredA store wasn't injected and AllowInMemoryFallback() wasn't called

Next steps ​

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