
# Protect a Go API

This quickstart protects a Go `net/http` API with KeenAuth access tokens. You add the `protect` middleware of the Go SDK, require a scope on one route and test the API offline with the SDK test kit. The middleware uses the standard library only and works with any router that accepts an `http.Handler`.

The API has three endpoints:

| Endpoint | Requires |
|---|---|
| `GET /api/public` | nothing |
| `GET /api/private` | a valid access token for your API |
| `GET /api/messages` | a valid access token with the `messages:read` scope |

## Before you begin

- A KeenAuth environment. Its issuer looks like `https://your-environment.eu.keenauth.app`.
- An API registered in the environment ([Create an API](/docs/api/management/apis/create)). Its `identifier`, for example `https://api.example.com`, is the audience of the tokens your API accepts.
- The `messages:read` scope on that API, granted to the application that calls it.
- Go 1.26 or later.

## 1. Install the SDK

```sh
go get github.com/keenauth/keenauth-go
```

The SDK is not published yet. Until it is, the example builds against the SDK source with a `replace` directive in `go.mod`.

## 2. Configure

The API reads its configuration from environment variables only:

| Variable | Required | Meaning |
|---|---|---|
| `KEENAUTH_ISSUER` | yes | Your environment's issuer |
| `KEENAUTH_AUDIENCE` | yes | Your API's identifier |
| `PUBLIC_BASE_URL` | yes | The URL clients call (see [Behind a proxy](#behind-a-proxy)) |
| `KEENAUTH_WEBHOOK_SECRET` | recommended | The `idp_whsec_` signing secret of the webhook endpoint that delivers `token.revoked` (two, comma separated, while you rotate it). Without it the API never learns of revoked tokens |
| `ADDR` | no | Listen address, default `:8080` |

## 3. Protect your routes

`NewHandler` creates the middleware and the routes. `m.Handler` requires a valid token; `m.Require` also requires scopes. The handler reads the verified claims with `protect.ClaimsFrom`.

```go
// NewHandler builds the API. The verifier is a parameter so that tests can pass the offline test kit's verifier;
// main passes a real one. The revocation cache is fed from the token.revoked webhook.
func NewHandler(v protect.AccessTokenVerifier, publicBaseURL string, revocation protect.RevocationChecker) (http.Handler, error) {
	m, err := protect.New(v, protect.Options{
		PublicBaseURL: publicBaseURL,
		Revocation:    revocation,
		OnError: func(r *http.Request, err error) {
			// The error never contains the token or claim values; never log the Authorization or DPoP header.
			log.Printf("rejected %s %s: %v", r.Method, r.URL.Path, err)
		},
	})
	if err != nil {
		return nil, err
	}

	mux := http.NewServeMux()
	// No token needed.
	mux.HandleFunc("GET /api/public", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(w, map[string]string{"message": "Hello from a public endpoint."})
	})
	// Any valid access token for this API.
	mux.Handle("GET /api/private", m.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, _ := protect.ClaimsFrom(r.Context())
		writeJSON(w, map[string]string{"message": "Hello from a private endpoint.", "sub": claims.Subject})
	})))
	// A valid access token that also carries the messages:read scope.
	mux.Handle("GET /api/messages", m.Require(protect.Requirement{Scopes: []string{"messages:read"}})(
		http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			writeJSON(w, map[string]any{"messages": []string{"Hello from a scoped endpoint."}})
		})))
	return mux, nil
}
```

`main` builds the real verifier from the configuration. The verifier fetches the signing keys from your environment the first time it sees a token.

```go
func main() {
	cfg, err := ConfigFromEnv(os.Getenv)
	if err != nil {
		log.Fatal(err)
	}
	// verify.New refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
	v, err := verify.New(verify.Config{Issuer: cfg.Issuer, Audience: cfg.Audience})
	if err != nil {
		log.Fatal(err)
	}
	revocation := protect.NewMemoryRevocationCache(0, nil)
	api, err := NewHandler(v, cfg.PublicBaseURL, revocation)
	if err != nil {
		log.Fatal(err)
	}
	h := api
	if len(cfg.WebhookSecrets) > 0 {
		wv, err := webhook.NewVerifier(cfg.WebhookSecrets) // the error never contains the secret
		if err != nil {
			log.Fatalf("KEENAUTH_WEBHOOK_SECRET: %v", err)
		}
		h = WithWebhook(api, NewWebhookHandler(wv, revocation, nil))
	} else {
		log.Print("KEENAUTH_WEBHOOK_SECRET is not set: token.revoked events are not received, so revoked tokens stay valid until they expire")
	}
	srv := &http.Server{Addr: cfg.Addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
	log.Printf("listening on %s", cfg.Addr)
	log.Fatal(srv.ListenAndServe())
}
```

## 4. Run and call the API

```sh
export KEENAUTH_ISSUER=https://your-environment.eu.keenauth.app
export KEENAUTH_AUDIENCE=https://api.example.com
export PUBLIC_BASE_URL=https://api.example.com
go run .
```

For a first call, get a token with the client credentials grant of a machine-to-machine application that has the `messages:read` scope on your API:

```sh
ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://api.example.com \
  -d scope=messages:read \
  https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)

curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"
```

## 5. Test without a network

The test kit package `keenauthtest` mints tokens signed with a public test key and builds a verifier that accepts only those tokens. The tests pass that verifier to `NewHandler`, so they need no network and no KeenAuth account. See [Offline test kit](/docs/sdk/testing).

```go
func newTestAPI(t *testing.T) (http.Handler, *protect.MemoryRevocationCache) {
	t.Helper()
	v, err := keenauthtest.NewVerifier(verify.Config{Audience: audience})
	if err != nil {
		t.Fatal(err)
	}
	cache := protect.NewMemoryRevocationCache(0, nil)
	h, err := NewHandler(v, baseURL, cache)
	if err != nil {
		t.Fatal(err)
	}
	return h, cache
}
```

```sh
go test ./...
```

The example's tests check that a valid token is accepted and that a missing, expired or wrong-audience token, a missing scope, a token in the URL query and a revoked token are rejected with the statuses below. They also send a DPoP-bound token with a proof for `PUBLIC_BASE_URL`. The tests also send signed `token.revoked` deliveries: a valid one revokes the token, a forged, tampered or stale one gets `400` and revokes nothing, and a repeated one has one effect.

## Responses

| Request | Status | `code` in the body |
|---|---|---|
| No `Authorization` header | `401` | `missing_token` |
| Expired token, token for another API, bad signature, revoked token | `401` | `invalid_token` |
| Token without the required scope | `403` | `insufficient_scope` |
| Token in the URL query (`?access_token=`) | `400` | `invalid_request` |

The body is `application/problem+json` and the `WWW-Authenticate` header carries the standard challenge. The full table is in [Protecting your API with middleware](/docs/sdk/protect-api#responses).

## Behind a proxy

If clients use DPoP, the middleware checks that each proof was made for the URL the client called. Set `PUBLIC_BASE_URL` to the external URL clients call, for example `https://api.example.com`, also behind a proxy or load balancer. It is required and must use `https` (`http` only for `localhost`, `127.0.0.1` or `[::1]`); the app refuses to start without it. The middleware never trusts `X-Forwarded-*` headers.

## Revoked tokens

An access token stays valid until it expires, so KeenAuth tells your API when tokens are revoked. The example passes an in-memory revocation cache to the middleware and serves a `token.revoked` webhook at `POST /webhooks/keenauth` (`main.go, webhook.go`) that feeds it.

1. In the dashboard, create a webhook endpoint with the URL `https://<your host>/webhooks/keenauth` that subscribes to `token.revoked`, and export its signing secret as `KEENAUTH_WEBHOOK_SECRET`.
2. The handler verifies the signature with the SDK on the raw body and answers `400` when it is invalid or the timestamp is more than five minutes off. It maps the event's selector (`access_token`, `session`, `user`, `client`, `org`, `org_user`, `client_user`) to a revocation marker and answers `204`; the middleware then rejects every matching token issued before `not_before`.
3. KeenAuth delivers at least once, so the handler applies each `webhook-id` once. If the marker cannot be stored it answers `503` and KeenAuth delivers the event again. It ignores other event types and selectors that no access token can match (`refresh_family`, `api`).

The handler logs the event id, the selector type and the reason, never the body, a signature or the secret. Each process has its own cache; with several instances, put the markers in a shared store behind the same `Revoke` method. See [Revocation cache](/docs/sdk/protect-api#revocation-cache-tokenrevoked).

## Next steps

- [Protecting your API with middleware](/docs/sdk/protect-api): step-up requirements, DPoP and every option.
- [Verifying tokens](/docs/sdk/token-verification): the verifier under the middleware.
- [Offline test kit](/docs/sdk/testing).
