Get started
API scopes
Integration guides
Features
Troubleshooting
Go
The de facto Go OIDC stack is golang.org/x/oauth2 for the OAuth flow plus github.com/coreos/go-oidc/v3 for ID token verification and JWKS handling.
go-oidc/v3 v3.18 (current at the time of writing) requires Go 1.25 — with the default GOTOOLCHAIN=auto (Go 1.21+) the newer toolchain auto-downloads. Readers on GOTOOLCHAIN=local and an older Go will see a confusing go.mod requires go >= 1.25 error.
Install
go get golang.org/x/oauth2
go get github.com/coreos/go-oidc/v3/oidc
Discover and configure
Discovery is a network call that can fail, so do it from main (or a setup(ctx) helper) rather than init() — that way a transient flake at boot returns a real error instead of panicking before logging is configured.
package main
import (
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
provider *oidc.Provider
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
)
func setupKenni(ctx context.Context) error {
p, err := oidc.NewProvider(ctx, "https://idp.kenni.is/<team-domain>")
if err != nil {
return err
}
provider = p
clientID := os.Getenv("KENNI_CLIENT_ID")
oauth2Config = oauth2.Config{
ClientID: clientID,
ClientSecret: os.Getenv("KENNI_CLIENT_SECRET"),
RedirectURL: "http://localhost:8080/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "national_id", oidc.ScopeOfflineAccess},
}
verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
// provider.Endpoint() only exposes auth_endpoint / token_endpoint.
// Use provider.Claims to read other discovery fields.
return provider.Claims(&discovery)
}
Login handler
PKCE is required for public clients and recommended for confidential ones too — it's a one-line addition that costs nothing and defends against authorization-code interception. Generate the verifier alongside state and nonce, and persist all three to a short-lived cookie or session.
func handleLogin(w http.ResponseWriter, r *http.Request) {
state := randomString(32)
nonce := randomString(32)
pkceVerifier := oauth2.GenerateVerifier()
setCookie(w, "state", state)
setCookie(w, "nonce", nonce)
setCookie(w, "pkce", pkceVerifier)
url := oauth2Config.AuthCodeURL(
state,
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkceVerifier),
)
http.Redirect(w, r, url, http.StatusFound)
}
Refresh tokens are requested via the offline_access scope (already in setupKenni) — no oauth2.AccessTypeOffline needed.
Callback handler
Handle the error / error_description query params first — Kenni redirects with these on user cancellation, and skipping the check produces a confusing "missing code" error from Exchange instead of the real reason. Verify state, exchange the code with the stored PKCE verifier, then verify the ID token's signature and its nonce claim.
func handleCallback(w http.ResponseWriter, r *http.Request) {
if e := r.URL.Query().Get("error"); e != "" {
http.Error(w, e+": "+r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != cookieValue(r, "state") {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
pkceVerifier := cookieValue(r, "pkce")
oauth2Token, err := oauth2Config.Exchange(
r.Context(),
r.URL.Query().Get("code"),
oauth2.VerifierOption(pkceVerifier),
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Error(w, "no id_token", http.StatusInternalServerError)
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if idToken.Nonce != cookieValue(r, "nonce") {
http.Error(w, "nonce mismatch", http.StatusBadRequest)
return
}
var claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
NationalID string `json:"national_id"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Persist oauth2Token + claims on your session.
}
Register http://localhost:8080/callback as a redirect URI on your application in the developer portal.
net/http ships no session library. For storing oauth2Token and claims server-side, gorilla/sessions and alexedwards/scs are both solid; for demos, an opaque-cookie-keyed in-memory map is enough. The cookie-helper stubs (randomString, setCookie, cookieValue, clearSession) used in this guide are minimal — for the state/nonce/pkce cookies, set HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 600, and Secure: r.TLS != nil so they work on http://localhost and on https:// prod alike.
Refresh
oauth2.Config.TokenSource(ctx, oauth2Token) returns a TokenSource that auto-refreshes using the refresh token. Wrap it in oauth2.NewClient(ctx, tokenSource) and use the returned *http.Client to call your APIs:
client := oauth2.NewClient(ctx, oauth2Config.TokenSource(ctx, oauth2Token))
resp, err := client.Get("https://my-api.example.com/widgets")
Authorization: Bearer <token> is added automatically and tokens rotate transparently. If you need a custom HTTP client (proxy, retries, mTLS) for both discovery and JWKS fetches, pass it via oidc.ClientContext(ctx, httpClient) before the NewProvider / Verify calls.
Sign out
Clearing the user's session cookie kills your tokens but leaves the Kenni session alive — the next sign-in silently re-authenticates. Neither golang.org/x/oauth2 nor coreos/go-oidc ship a logout helper, so build the redirect by hand from the discovery doc you cached in setupKenni:
func handleLogout(w http.ResponseWriter, r *http.Request) {
idToken := /* the id_token from the user's session */
clearSession(w) // clear local session first — a failed end-session redirect still leaves us logged out locally
u, _ := url.Parse(discovery.EndSessionEndpoint)
q := u.Query()
q.Set("id_token_hint", idToken)
q.Set("post_logout_redirect_uri", "http://localhost:8080/")
q.Set("client_id", oauth2Config.ClientID)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
Register http://localhost:8080/ on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.
Verifying access tokens
The auth flow above gets a user signed in. The other half of the spec is the API server that receives a Kenni access token on an incoming request and has to validate it.
The trick is non-obvious: provider.Verifier is named "ID token verifier", but it works on any JWT — it just checks iss, aud, signature, and expiry, which is the same shape an access token has. Reuse the same Provider, but pass the API audience as ClientID:
var apiVerifier *oidc.IDTokenVerifier
func setupAPIVerifier() {
apiAudience := os.Getenv("KENNI_API_AUDIENCE") // e.g. "<client_id>-api"
apiVerifier = provider.Verifier(&oidc.Config{ClientID: apiAudience})
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "missing bearer", http.StatusUnauthorized)
return
}
raw := strings.TrimPrefix(auth, "Bearer ")
idToken, err := apiVerifier.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "invalid_token", http.StatusUnauthorized)
return
}
var claims struct {
Sub string `json:"sub"`
Scope string `json:"scope"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
required := os.Getenv("KENNI_API_SCOPE")
if !slices.Contains(strings.Fields(claims.Scope), required) {
http.Error(w, "insufficient_scope", http.StatusForbidden)
return
}
// ...handle the authenticated request.
}
The apiVerifier instance handles JWKS fetching and key rotation transparently — reuse a single instance across all incoming requests. Access tokens are JWTs only when you request a custom API scope; see API scopes for the opaque-vs-JWT distinction.
Client credentials (M2M)
For machine-to-machine calls (no user, no browser), use the client-credentials grant. There's no helper in go-oidc or oauth2 for it against a discovered endpoint — http.PostForm with HTTP Basic auth is the shortest path:
func clientCredentialsToken(ctx context.Context, scope string) (string, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("scope", scope)
req, err := http.NewRequestWithContext(ctx, "POST", discovery.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(
url.QueryEscape(os.Getenv("KENNI_CLIENT_ID")),
url.QueryEscape(os.Getenv("KENNI_CLIENT_SECRET")),
)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, body)
}
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.AccessToken, nil
}
Per RFC 6749 §2.3.1, client_id and client_secret are form-urlencoded before being joined for HTTP Basic — the url.QueryEscape calls above handle special characters in either credential. The returned access token is a JWT with aud = <api-audience>; verify it with the same apiVerifier shown above.