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 need | Notes |
|---|---|
| Go 1.27 | Wicket's go.mod declares go 1.27; older toolchains can't resolve it |
curl | For calling the endpoints by hand |
mkdir wicket-quickstart && cd wicket-quickstart
go mod init example.com/quickstart
go get github.com/gonewx/wicket@v0.1.4About 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.
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
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
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 theorders.readscope. Tokens carryordersin theiraudclaim; the resource's own secret,api-secret, is used to call the introspection endpoint. - Client
billing-worker: allowed only theclient_credentialsgrant and only theorders.readscope.
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
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 beforeFreeze().
5. Mount the middleware
handler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))Both layers are required:
hosting.ScopeMiddleware(root)creates a dependency-injection scope for each request;srv.UseWicket()intercepts protocol paths (/connect/*,/.well-known/*) and hands every other request to yourappunchanged.
Run and verify
go run .In another terminal:
Discovery document — confirm the server is up:
curl -s http://localhost:5000/.well-known/openid-configurationIssue a token — client credentials over HTTP Basic authentication:
curl -s -u billing-worker:change-me \
-d grant_type=client_credentials \
-d scope=orders.read \
http://localhost:5000/connect/token{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 3600,
"scope": "orders.read",
"token_type": "Bearer"
}Base64url-decode the middle segment of access_token to see its claims:
{
"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:
curl -s -u orders:api-secret \
-d token=<access_token from the previous step> \
http://localhost:5000/connect/introspect{ "active": true, "aud": ["orders"], "client_id": "billing-worker", "scope": "orders.read", "...": "..." }Your own routes are untouched:
curl -s http://localhost:5000/
# host appTroubleshooting
| Symptom | Cause |
|---|---|
The token endpoint returns no signing credential available | No 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 declared | A store wasn't injected and AllowInMemoryFallback() wasn't called |
Next steps
- Core concepts: how clients, resources, scopes and tokens relate
- Embedding in your app: how login and consent pages work with Wicket
- Authorization code + PKCE: issue tokens to web and mobile apps with a user present
- Persistence: the storage wiring you must finish before going live