Go server SDK

Module github.com/keenauth/keenauth-go is the Go client of the KeenAuth management API. Typed methods for every /v1 resource are generated from the OpenAPI document (package management, embedded in keenauth.Client). The hand-written core adds authentication, retries, idempotency keys, typed errors and deprecation warnings. The module uses only the standard library. It is not published yet.

For sign-in in a net/http app, see Go net/http sessions. To protect your own API, see Protecting your API.

Create a client with an API key

import (
	"context"
	"fmt"
	"os"

	keenauth "github.com/keenauth/keenauth-go"
)

c, err := keenauth.NewClient("https://api.eu.keenauth.com/v1", os.Getenv("KEENAUTH_API_KEY"))
if err != nil {
	panic(err)
}
user, _, err := c.UsersAPI.GetUser(context.Background(), "usr_01j9zzzzzzzzzzzzzzzzzzzz").Execute()
if err != nil {
	panic(err)
}
fmt.Println(user.GetEmail())

The base URL is your region's API address followed by /v1. The key (idp_sk_… or idp_rk_…) is sent only in the Authorization header and only to that address; a redirect to another host or to plain http never carries it. Plain http is accepted only for localhost.

Options:

Authenticate with private_key_jwt

Instead of a static key, a management application can sign in with its own key pair. The SDK gets a management token from your environment's token endpoint, keeps it in memory and replaces it 60 seconds before it expires. Concurrent calls share one token request.

  1. In the dashboard, create a management application and register its public key (as a JWKS URL or directly; up to 3 active keys).
  2. Keep the private key on your server, for example in a secret file or a KMS.
import (
	"os"

	keenauth "github.com/keenauth/keenauth-go"
	"github.com/keenauth/keenauth-go/assertion"
)

pemBytes, err := os.ReadFile("/run/secrets/keenauth-mgmt-key.pem")
if err != nil {
	panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes)
if err != nil {
	panic(err)
}
c, err := keenauth.NewClientWithPrivateKeyJWT("https://api.eu.keenauth.com/v1", keenauth.PrivateKeyJWT{
	Issuer:   "https://acme.eu.keenauth.app", // your environment's issuer, exactly
	ClientID: "app_01j9zzzzzzzzzzzzzzzzzzzz",
	Key:      key,
	KeyID:    "mgmt-key-2026-11", // the kid you registered; optional with a single key
	Scope:    "users:read users:write", // optional; default: the grant's default scopes
})

What the SDK sends:

Key is any crypto.Signer, so a key held in a KMS or HSM works without exporting it. The management token lives 10 minutes. If the API answers 401, the SDK drops the cached token and the next call gets a new one.

A refused token request returns a *keenauth.TokenError with the HTTP status and the OAuth error code, for example invalid_client for an unknown key or a wrong KeyID. It never contains the assertion or a token:

var te *keenauth.TokenError
if errors.As(err, &te) {
	log.Printf("token request refused: %d %s", te.Status, te.Code)
}

Retries and idempotency keys

The SDK retries a call at most 3 times in total, and only when both are true:

Every POST operation that accepts an Idempotency-Key (all of them except the side-effect-free users:search and …:preview calls and users/{id}:unlock) gets a new UUIDv4 key, and every retry of that call sends the same key and the same body, so the operation runs at most once. To make a call idempotent across your own restarts, pass your own key (16 to 64 characters of A-Z a-z 0-9 _ -), for example .IdempotencyKey(orderKey); the SDK never replaces it. A PATCH that carries If-Match (every versioned update) is retried on 429 only: the rate limiter refuses a request before it runs, and If-Match keeps a change from being applied twice. Any other PATCH is never retried.

Between attempts the SDK waits as long as Retry-After says (seconds or an HTTP date). Without it, it waits 0.5 seconds, then 1 second, each with ±25 % jitter. If Retry-After asks for more than 60 seconds, or the wait would pass your context's deadline, the SDK returns the response instead of waiting. Cancelling the context stops the wait.

Iterate over a list

List methods return one page: data and list_metadata with the before and after cursors. keenauth.All follows after and yields every item. It fetches the next page only when you reach it, so breaking out of the loop stops further requests. Set the page size and filters on the request as usual:

for u, err := range keenauth.All(c.UsersAPI.ListUsers(ctx).Limit(100).Status("active")) {
	if err != nil {
		return err // the iteration ends after an error
	}
	fmt.Println(u.GetId(), u.GetEmail())
}

keenauth.AllBefore follows before instead, for example towards newer entries of ListLogs. Every generated list method works with both, except ListEvents of the Events API, which pages with next_cursor and has_more. A range-over-func loop needs Go 1.23 or later.

Verify webhooks

Package github.com/keenauth/keenauth-go/webhook checks the signature of a webhook delivery (Standard Webhooks). Give it the endpoint's signing secret (idp_whsec_…) and pass the raw body exactly as received:

import (
	"io"
	"net/http"
	"os"

	"github.com/keenauth/keenauth-go/webhook"
)

verifier, err := webhook.NewVerifier([]string{os.Getenv("KEENAUTH_WEBHOOK_SECRET")})
if err != nil {
	panic(err) // webhook.ErrSecret: the secret is not well formed
}

http.HandleFunc("POST /webhooks/keenauth", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
		return
	}
	if err := verifier.Verify(r.Header, body); err != nil {
		http.Error(w, "invalid webhook", http.StatusBadRequest)
		return
	}
	// Handle the event. Store webhook-id and skip a delivery you have already processed.
	w.WriteHeader(http.StatusNoContent)
})

Verify accepts a delivery when webhook-timestamp is within 5 minutes of now (in both directions) and one of the v1 signatures in webhook-signature matches. Signatures are compared in constant time, and other signature versions are ignored. The error tells you why a delivery was refused; each matches one of webhook.ErrHeaders (a header is missing), webhook.ErrTimestamp, webhook.ErrSignature or webhook.ErrSecret with errors.Is.

When you rotate the secret, pass both secrets, new and old, until the old one stops being used: webhook.NewVerifier([]string{newSecret, oldSecret}). During the rotation KeenAuth may also send two signatures in one header; either one is enough.

Typed events and claims

Package github.com/keenauth/keenauth-go/events has one struct per customer event type, generated from the public event schemas. webhook.Verifier.VerifyEvent verifies a delivery and parses it; events.Parse parses a body you have already verified. Use a type switch to get the fields of one type:

ev, err := verifier.VerifyEvent(r.Header, body)
if err != nil {
	http.Error(w, "invalid webhook", http.StatusBadRequest) // webhook.Err… or events.ErrParse
	return
}
switch e := ev.(type) {
case *events.UserCreated:
	log.Printf("user %s created", e.Data.Object.ID)
case *events.EmailSendingSuspended:
	log.Printf("sending suspended: %s", e.Data.Object.Reason)
case *events.Unknown:
	// A type this SDK version does not know yet. e.Type and e.Raw() are still available.
}

Package github.com/keenauth/keenauth-go/claims types the claims of a verified token: claims.ParseIDToken for an ID token (OpenID Connect Core) and claims.ParseAccessToken for an access token (RFC 9068). They check claim types but verify nothing, so call them on the Claims map that package verify returns:

at, err := v.VerifyAccessToken(ctx, token, nil)
if err != nil {
	return err
}
c, err := claims.ParseAccessToken(at.Claims)
if err != nil {
	return err
}
log.Printf("client %s, org %s, roles %v", c.ClientID, c.OrgID, c.OrgRoles)
_ = c.Custom["tenant_tier"] // your own claims

aud is always a slice (claims.Audience). Claims that are not part of the contract are your own claims and are kept in Custom.

Errors

An API error is an RFC 9457 problem document. keenauth.AsProblem(err) returns it with the machine-readable Code. An unknown code is returned as it is, so new server codes do not break your client:

if _, _, err := c.UsersAPI.GetUser(ctx, id).Execute(); err != nil {
	if pe, ok := keenauth.AsProblem(err); ok && pe.Code == "not_found" {
		// …
	}
}

Deprecation warnings

When a response carries a Deprecation (RFC 9745) or Sunset (RFC 8594) header, the SDK logs one warning per operation and header value for the whole process, at WARN level on the logger you set with WithLogger. The warning names the method and the path with identifiers replaced by {id}, the header values and any Link with rel="deprecation" or rel="sunset". It never contains IDs, tokens or keys, and the call itself is not affected.

level=WARN msg="keenauth: the API operation is deprecated" operation="GET /users/{id}" deprecation=@1767225600 sunset="Wed, 01 Jul 2026 00:00:00 GMT"