Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Protect a Go API with DPoP-bound access tokens

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

This guide protects a Go REST API with DPoP-bound access tokens from Nauthera and registers the Go services that call it. You need a Nauthera issuer with its operator running in the cluster, and Go 1.25 or later. At the end, the API accepts only tokens bound to a key its caller holds, and each caller authenticates with a private key instead of a secret. The main limit: the API refuses bearer tokens, so every caller must be able to request DPoP-bound tokens.

A Go API validates Nauthera access tokens locally: it reads the issuer's discovery document once, keeps the issuer's JWKS cached, and checks each token's signature and claims in process. This guide sets it up as strictly as Nauthera allows:

  • Every caller authenticates with a private key (private_key_jwt, RFC 7523). Nauthera holds no secret that could stand in for it.
  • Every access token is bound to a second key the caller holds (DPoP, RFC 9449). The API refuses a token without that binding, a request without a valid proof, and any token sent as Bearer. A stolen token is useless without the caller's DPoP key.

It uses jwx v3 on both sides, because it covers every piece this needs (JWKS caching, header checks, the proof's embedded key with its curve check, RFC 7638 thumbprints, and signing), where the dedicated go-dpop library has no algorithm allowlist or replay protection.

The API is named https://orders.example.com/, serves /orders, and expects the scopes orders:read and orders:write. jwx v3 needs Go 1.25 or later.

1. Register the callers

The API itself needs no client. Each service that calls it does, and holds two keys:

KeyProvesLives
Assertion keyWho the caller is, at the token endpointA Kubernetes Secret you create. Nauthera fetches its public half from jwksUri.
DPoP keyThat the caller sent the tokenGenerated in memory at startup, never stored

Keeping them apart means a leaked DPoP key cannot get new tokens, and rotating the assertion key leaves running tokens alone.

Generate the assertion key and store it as a Secret:

openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out key.pem
kubectl -n billing create secret generic billing-worker-assertion-key --from-file=key.pem
rm key.pem

The caller serves the public half on port 8081 (step 7). Nauthera fetches it itself, so jwksUri must be https on a public DNS name: it refuses loopback, private and link-local addresses and does not follow redirects. A cluster-local Service URL does not work. Expose that one path through your ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: billing-worker-jwks
  namespace: billing
spec:
  tls:
    - hosts: [billing-worker.example.com]
      secretName: billing-worker-tls
  rules:
    - host: billing-worker.example.com
      http:
        paths:
          - path: /.well-known/jwks.json
            pathType: Exact
            backend:
              service: { name: billing-worker, port: { number: 8081 } }

Then register the client:

apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: billing      # a namespace the organization admits
spec:
  clientId: billing-worker
  displayName: Billing worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: private_key_jwt
  tokenEndpointAuthSigningAlg: ES256
  jwksUri: https://billing-worker.example.com/.well-known/jwks.json
  accessTokenTtlSeconds: 300
kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker   # PHASE Ready, reason Registered

There is no secret block: the OIDCClient schema refuses a generated secret for private_key_jwt when the manifest is applied, so the operator writes no Secret. The Deployment names the issuer and client itself and mounts the key:

# in the container
env:
  - { name: NAUTHERA_ISSUER, value: https://auth.example.com }
  - { name: NAUTHERA_CLIENT_ID, value: billing-worker }
  - { name: NAUTHERA_ASSERTION_KEY, value: /var/run/secrets/nauthera/key.pem }
  - { name: ORDERS_API, value: https://orders.example.com/ }
volumeMounts:
  - { name: assertion-key, mountPath: /var/run/secrets/nauthera, readOnly: true }
# in the pod
volumes:
  - name: assertion-key
    secret: { secretName: billing-worker-assertion-key }

To rotate the assertion key, serve the new public key next to the old one (add it to the set JWKSHandler builds) and follow Keys instead of secrets.

A web app whose users call the API registers as in the Better Auth guide, with the API's scopes in its spec.scopes and resource=https://orders.example.com/ on its authorization request. It must also send a DPoP proof to /token and with every API call, or this API refuses its tokens.

2. Add the dependencies

go get github.com/lestrrat-go/jwx/v3 github.com/lestrrat-go/httprc/v3

3. Write the token verifier

// Package auth admits only DPoP-bound Nauthera access tokens (RFC 9068, RFC 9449).
package auth
 
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"slices"
	"strings"
	"time"
 
	"github.com/lestrrat-go/httprc/v3"
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)
 
// Claims are the access token claims this API reads.
type Claims struct {
	Subject  string
	ClientID string
	Scopes   []string
	JKT      string // cnf.jkt: the thumbprint of the caller's DPoP key
}
 
type confirmation struct {
	JKT string `json:"jkt"`
}
 
type Verifier struct {
	issuer   string
	audience string
	keys     jwk.Set
	replay   *replayCache
}
 
// NewVerifier reads the issuer's discovery document and starts a JWKS cache
// that refreshes in the background until ctx is cancelled.
func NewVerifier(ctx context.Context, issuer, audience string) (*Verifier, error) {
	jwksURI, err := discoverJWKS(ctx, issuer)
	if err != nil {
		return nil, err
	}
	cache, err := jwk.NewCache(ctx, httprc.NewClient())
	if err != nil {
		return nil, err
	}
	if err := cache.Register(ctx, jwksURI, jwk.WithMaxInterval(5*time.Minute)); err != nil {
		return nil, fmt.Errorf("load jwks: %w", err)
	}
	keys, err := cache.CachedSet(jwksURI)
	if err != nil {
		return nil, err
	}
	return &Verifier{issuer: issuer, audience: audience, keys: keys, replay: newReplayCache()}, nil
}
 
// verifyToken checks a raw access token and returns its claims.
func (v *Verifier) verifyToken(raw string) (*Claims, error) {
	tok, err := jwt.ParseString(raw,
		jwt.WithKeyProvider(jws.KeyProviderFunc(v.accessTokenKey)),
		jwt.WithIssuer(v.issuer),
		jwt.WithAudience(v.audience),
		jwt.WithRequiredClaim(jwt.ExpirationKey),
		jwt.WithAcceptableSkew(5*time.Second),
		jwt.WithTypedClaim("cnf", confirmation{}),
	)
	if err != nil {
		return nil, err
	}
	var c Claims
	var scope string
	var cnf confirmation
	c.Subject, _ = tok.Subject()
	_ = tok.Get("client_id", &c.ClientID)
	_ = tok.Get("scope", &scope)
	_ = tok.Get("cnf", &cnf)
	if cnf.JKT == "" {
		return nil, errors.New("token is not DPoP-bound")
	}
	c.Scopes, c.JKT = strings.Fields(scope), cnf.JKT
	return &c, nil
}
 
// accessTokenKey hands the parser the issuer key named by kid, and only for an
// ES256 token typed at+jwt.
func (v *Verifier) accessTokenKey(_ context.Context, sink jws.KeySink, sig *jws.Signature, _ *jws.Message) error {
	h := sig.ProtectedHeaders()
	// An ID token is signed by the same keys; only typ tells them apart.
	if typ, _ := h.Type(); typ != "at+jwt" {
		return errors.New("not an access token")
	}
	if alg, _ := h.Algorithm(); alg != jwa.ES256() {
		return errors.New("algorithm not allowed")
	}
	kid, _ := h.KeyID()
	key, ok := v.keys.LookupKeyID(kid)
	if !ok {
		return errors.New("unknown key id")
	}
	sink.Key(jwa.ES256(), key)
	return nil
}
 
func (c *Claims) HasScope(scope string) bool { return slices.Contains(c.Scopes, scope) }
 
func discoverJWKS(ctx context.Context, issuer string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		issuer+"/.well-known/openid-configuration", nil)
	if err != nil {
		return "", err
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("fetch discovery: %w", err)
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return "", fmt.Errorf("fetch discovery: %s", res.Status)
	}
	var doc struct {
		Issuer  string `json:"issuer"`
		JWKSURI string `json:"jwks_uri"`
	}
	if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
		return "", fmt.Errorf("decode discovery: %w", err)
	}
	if doc.Issuer != issuer {
		return "", fmt.Errorf("discovery names issuer %q, want %q", doc.Issuer, issuer)
	}
	return doc.JWKSURI, nil
}

What it checks:

CheckWhere
Header typ is at+jwtaccessTokenKey
alg is ES256, signed by the issuer key kid namesaccessTokenKey
iss is the issuer, aud contains this APIWithIssuer, WithAudience
exp is present; exp, nbf and iat hold within 5 secondsWithRequiredClaim, WithAcceptableSkew
The token is DPoP-bound (cnf.jkt)verifyToken

Nauthera signs access tokens with keys.default_alg, which is ES256 unless the server holds only an RSA key or is set to RS256. Pin the algorithm the server uses. The JWKS cache refreshes at least every five minutes. Nauthera's rotation procedure publishes a new key at least eleven minutes before signing with it, so the API needs no restart.

4. Verify the DPoP proof

package auth
 
import (
	"context"
	"crypto"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"maps"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
 
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)
 
// A proof is accepted from 60 seconds before now to 5 seconds after, the
// window nauthera-server applies at its own endpoints.
const (
	proofMaxAge    = 60 * time.Second
	proofMaxFuture = 5 * time.Second
)
 
// verifyProof checks the request's DPoP proof (RFC 9449 ยง4.3) against the
// access token it accompanies and the key that token is bound to.
func (v *Verifier) verifyProof(r *http.Request, accessToken, jkt string) error {
	proofs := r.Header.Values("DPoP")
	if len(proofs) != 1 {
		return errors.New("want exactly one DPoP header")
	}
	var thumbprint string
	proof, err := jwt.ParseString(proofs[0], jwt.WithValidate(false),
		jwt.WithKeyProvider(jws.KeyProviderFunc(
			func(_ context.Context, sink jws.KeySink, sig *jws.Signature, _ *jws.Message) error {
				h := sig.ProtectedHeaders()
				typ, _ := h.Type()
				alg, _ := h.Algorithm()
				key, ok := h.JWK()
				if typ != "dpop+jwt" || alg != jwa.ES256() || !ok {
					return errors.New("not an ES256 DPoP proof")
				}
				// A jwx private key also satisfies ECDSAPublicKey, so it is
				// refused by IsPrivateKey, not by the type.
				ec, ok := key.(jwk.ECDSAPublicKey)
				if !ok {
					return errors.New("proof key is not an EC key")
				}
				crv, _ := ec.Crv()
				if private, _ := jwk.IsPrivateKey(key); private || crv != jwa.P256() {
					return errors.New("proof key is not a public P-256 key")
				}
				tp, err := key.Thumbprint(crypto.SHA256)
				if err != nil {
					return err
				}
				thumbprint = base64.RawURLEncoding.EncodeToString(tp)
				sink.Key(jwa.ES256(), key)
				return nil
			})))
	if err != nil {
		return err
	}
 
	var htm, htu, ath string
	_ = proof.Get("htm", &htm)
	_ = proof.Get("htu", &htu)
	_ = proof.Get("ath", &ath)
	jti, _ := proof.JwtID()
	iat, _ := proof.IssuedAt()
	sum := sha256.Sum256([]byte(accessToken))
	now := time.Now()
	switch {
	case subtle.ConstantTimeCompare([]byte(thumbprint), []byte(jkt)) != 1:
		return errors.New("proof key is not the key the token is bound to")
	case ath != base64.RawURLEncoding.EncodeToString(sum[:]):
		return errors.New("ath does not match the access token")
	case htm != r.Method:
		return errors.New("htm does not match the request method")
	case htu == "" || normalizeHTU(htu) != normalizeHTU(v.audience+strings.TrimPrefix(r.URL.EscapedPath(), "/")):
		return errors.New("htu does not match the request URL")
	case iat.Before(now.Add(-proofMaxAge)) || iat.After(now.Add(proofMaxFuture)):
		return errors.New("iat is outside the acceptance window")
	case jti == "":
		return errors.New("jti is missing")
	}
	// Last, so a proof that fails any other check cannot use up a jti.
	if !v.replay.claim(jkt+" "+jti, proofMaxAge+proofMaxFuture) {
		return errors.New("proof replayed")
	}
	return nil
}
 
// normalizeHTU applies RFC 9449 ยง4.3 step 9: scheme and host in lower case, the
// default port dropped, query and fragment ignored, an empty path written as /.
func normalizeHTU(raw string) string {
	u, err := url.Parse(raw)
	if err != nil || !u.IsAbs() || u.Host == "" {
		return ""
	}
	host := strings.TrimSuffix(strings.ToLower(u.Host), map[string]string{"https": ":443", "http": ":80"}[u.Scheme])
	path := u.EscapedPath()
	if path == "" {
		path = "/"
	}
	return u.Scheme + "://" + host + path
}
 
// replayCache remembers each proof's jti for as long as the proof could still
// be accepted.
type replayCache struct {
	mu      sync.Mutex
	seen    map[string]time.Time
	sweepAt time.Time
}
 
func newReplayCache() *replayCache { return &replayCache{seen: map[string]time.Time{}} }
 
// claim records key until ttl from now and reports whether it was unused.
func (c *replayCache) claim(key string, ttl time.Duration) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	now := time.Now()
	if now.After(c.sweepAt) {
		maps.DeleteFunc(c.seen, func(_ string, until time.Time) bool { return now.After(until) })
		c.sweepAt = now.Add(ttl)
	}
	if until, ok := c.seen[key]; ok && now.Before(until) {
		return false
	}
	c.seen[key] = now.Add(ttl)
	return true
}

What it checks (RFC 9449 ยง4.3):

CheckRefused when
One DPoP headerthere are none or several
Header typ is dpop+jwt, alg is ES256another type or algorithm, including none and HS256
Header jwk is a public P-256 key, and the signature verifies with ita private member, another key type or curve, or a bad signature
jwk's RFC 7638 thumbprint is the token's cnf.jktthe proof was signed by another key
ath is the base64url SHA-256 of the access tokenthe proof was made for another token
htm and htu are this request's method and URLthe proof was made for another request
iat is at most 60 seconds old and 5 seconds aheadthe proof is stale
jti was not seen beforethe proof is replayed

The expected htu is built from the API's resource URI and the request path, never from the Host header, which a proxy may rewrite and a client controls. Serve the API at the root of its resource URI, or build the URL from its public base instead.

The replay cache lives in process. With more than one replica a proof could be replayed once against each other replica, so keep it in a store they share, for example Redis with SET dpop:<jkt>:<jti> 1 NX PX 65000.

The API issues no DPoP nonces. They are optional at a resource server (RFC 9449 ยง9), and the tight iat window with the jti cache already bounds a replay. A nonce would need state shared by every replica and cost the caller a second round trip.

5. Add the middleware

package auth
 
import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
)
 
type ctxKey struct{}
 
// ClaimsFrom returns the claims Require stored on the request context.
func ClaimsFrom(ctx context.Context) *Claims {
	c, _ := ctx.Value(ctxKey{}).(*Claims)
	return c
}
 
// Require lets a request through only with a valid DPoP-bound access token,
// a valid proof for it, and a token granting scope.
func (v *Verifier) Require(scope string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
		if !ok || !strings.EqualFold(scheme, "DPoP") {
			deny(w, r, http.StatusUnauthorized, "", nil)
			return
		}
		claims, err := v.verifyToken(raw)
		if err != nil {
			deny(w, r, http.StatusUnauthorized, `error="invalid_token", `, err)
			return
		}
		if err := v.verifyProof(r, raw, claims.JKT); err != nil {
			deny(w, r, http.StatusUnauthorized, `error="invalid_dpop_proof", `, err)
			return
		}
		if !claims.HasScope(scope) {
			deny(w, r, http.StatusForbidden, fmt.Sprintf(`error="insufficient_scope", scope=%q, `, scope), nil)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, claims)))
	})
}
 
// deny answers with a DPoP challenge (RFC 9449 ยง7.1). The reason goes to the
// log, never to the caller.
func deny(w http.ResponseWriter, r *http.Request, status int, params string, reason error) {
	if reason != nil {
		slog.InfoContext(r.Context(), "request refused", "path", r.URL.Path, "reason", reason)
	}
	w.Header().Set("WWW-Authenticate", `DPoP `+params+`algs="ES256"`)
	w.WriteHeader(status)
}
RequestAnswer
No Authorization, or a scheme other than DPoP (including Bearer)401, WWW-Authenticate: DPoP algs="ES256"
The access token fails a check401, DPoP error="invalid_token", algs="ES256"
The proof fails a check401, DPoP error="invalid_dpop_proof", algs="ES256"
The token lacks the scope403, DPoP error="insufficient_scope", scope="orders:read", algs="ES256"

The challenges follow RFC 9449 ยง7.1. The body never says why a request failed: the reason is for your logs, not for the caller.

6. Wire it up

package main
 
import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/signal"
 
	"example.com/orders/auth"
)
 
func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()
 
	verifier, err := auth.NewVerifier(ctx,
		os.Getenv("NAUTHERA_ISSUER"),   // https://auth.example.com
		os.Getenv("NAUTHERA_AUDIENCE"), // https://orders.example.com/
	)
	if err != nil {
		log.Fatal(err)
	}
 
	mux := http.NewServeMux()
	mux.Handle("GET /orders", verifier.Require("orders:read", http.HandlerFunc(listOrders)))
	mux.Handle("POST /orders", verifier.Require("orders:write", http.HandlerFunc(createOrder)))
	log.Fatal(http.ListenAndServe(":8090", mux))
}
 
func listOrders(w http.ResponseWriter, r *http.Request) {
	c := auth.ClaimsFrom(r.Context())
	// c.Subject is the user, or the client for a client_credentials token.
	// c.ClientID is the application that called.
	json.NewEncoder(w).Encode(map[string]string{"subject": c.Subject, "client": c.ClientID})
}
 
func createOrder(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusCreated)
}

The issuer must be spelled exactly as Nauthera publishes it, without a trailing slash. For an organization other than the main one it is https://auth.example.com/orgs/acme. The audience is the API's resource URI in the normalized form Nauthera puts in aud, with the trailing slash.

7. Call it

The billing-worker service gets a token with client_credentials, authenticating with an assertion signed by its assertion key and binding the token with a DPoP proof. It then sends each request with Authorization: DPoP and a fresh proof carrying ath. An http.RoundTripper does all of it:

// Package nauthera gets DPoP-bound access tokens with client_credentials and
// private_key_jwt, and sends them with a fresh DPoP proof on every request.
package nauthera
 
import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
 
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)
 
// Transport is an http.RoundTripper that authenticates every request it sends.
type Transport struct {
	Issuer       string  // https://auth.example.com
	ClientID     string  // billing-worker
	Resource     string  // https://orders.example.com/
	Scope        string  // orders:read
	AssertionKey jwk.Key // the private key whose public half is at jwksUri
	ClientSecret string  // set only against a local server; replaces the assertion
	Base         http.RoundTripper
 
	dpopKey jwk.Key
	dpopPub jwk.Key
 
	mu      sync.Mutex
	token   string
	expires time.Time
	nonce   string
}
 
// NewDPoPKey gives t a DPoP key that lives only in this process. Tokens bound
// to it die with the process, which their short lifetime already accepts.
func (t *Transport) NewDPoPKey() error {
	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return err
	}
	if t.dpopKey, err = jwk.Import(priv); err != nil {
		return err
	}
	t.dpopPub, err = t.dpopKey.PublicKey()
	return err
}
 
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
	token, err := t.accessToken(req.Context())
	if err != nil {
		return nil, err
	}
	proof, err := t.proof(req.Method, req.URL, "", token)
	if err != nil {
		return nil, err
	}
	req = req.Clone(req.Context())
	req.Header.Set("Authorization", "DPoP "+token)
	req.Header.Set("DPoP", proof)
	return t.base().RoundTrip(req)
}
 
func (t *Transport) accessToken(ctx context.Context) (string, error) {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.token != "" && time.Now().Before(t.expires) {
		return t.token, nil
	}
	// The token endpoint always requires a DPoP nonce: the first attempt
	// without one, or with an expired one, is answered use_dpop_nonce.
	for range 2 {
		res, err := t.requestToken(ctx)
		if err != nil {
			return "", err
		}
		if n := res.Header.Get("DPoP-Nonce"); n != "" {
			t.nonce = n
		}
		var body struct {
			AccessToken string `json:"access_token"`
			TokenType   string `json:"token_type"`
			ExpiresIn   int    `json:"expires_in"`
			Error       string `json:"error"`
		}
		err = json.NewDecoder(res.Body).Decode(&body)
		res.Body.Close()
		switch {
		case err != nil:
			return "", fmt.Errorf("token response: %w", err)
		case res.StatusCode == http.StatusBadRequest && body.Error == "use_dpop_nonce":
			continue
		case res.StatusCode != http.StatusOK:
			return "", fmt.Errorf("token request: %s %s", res.Status, body.Error)
		case body.TokenType != "DPoP":
			return "", fmt.Errorf("token request: token_type %q, want DPoP", body.TokenType)
		}
		t.token = body.AccessToken
		t.expires = time.Now().Add(time.Duration(body.ExpiresIn)*time.Second - 30*time.Second)
		return t.token, nil
	}
	return "", fmt.Errorf("token request: nonce refused twice")
}
 
func (t *Transport) requestToken(ctx context.Context) (*http.Response, error) {
	form := url.Values{
		"grant_type": {"client_credentials"},
		"scope":      {t.Scope},
		"resource":   {t.Resource},
	}
	if t.ClientSecret == "" {
		// An assertion's jti is single use, so each attempt signs a new one.
		assertion, err := t.assertion()
		if err != nil {
			return nil, err
		}
		form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
		form.Set("client_assertion", assertion)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.Issuer+"/token",
		strings.NewReader(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if t.ClientSecret != "" {
		req.SetBasicAuth(url.QueryEscape(t.ClientID), url.QueryEscape(t.ClientSecret))
	}
	proof, err := t.proof(http.MethodPost, req.URL, t.nonce, "")
	if err != nil {
		return nil, err
	}
	req.Header.Set("DPoP", proof)
	return t.base().RoundTrip(req)
}
 
// assertion is the RFC 7523 client assertion: iss and sub are the client, aud
// is the issuer, and it expires a minute after it is made.
func (t *Transport) assertion() (string, error) {
	now := time.Now()
	tok, err := jwt.NewBuilder().
		Issuer(t.ClientID).
		Subject(t.ClientID).
		Audience([]string{t.Issuer}).
		IssuedAt(now).
		Expiration(now.Add(time.Minute)).
		JwtID(rand.Text()).
		Build()
	if err != nil {
		return "", err
	}
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), t.AssertionKey))
	return string(signed), err
}
 
// proof is a DPoP proof (RFC 9449 ยง4.2) for one request. ath binds it to the
// access token it accompanies.
func (t *Transport) proof(method string, target *url.URL, nonce, accessToken string) (string, error) {
	htu := url.URL{Scheme: target.Scheme, Host: target.Host, Path: target.Path, RawPath: target.RawPath}
	b := jwt.NewBuilder().
		JwtID(rand.Text()).
		IssuedAt(time.Now()).
		Claim("htm", method).
		Claim("htu", htu.String())
	if nonce != "" {
		b = b.Claim("nonce", nonce)
	}
	if accessToken != "" {
		sum := sha256.Sum256([]byte(accessToken))
		b = b.Claim("ath", base64.RawURLEncoding.EncodeToString(sum[:]))
	}
	tok, err := b.Build()
	if err != nil {
		return "", err
	}
	h := jws.NewHeaders()
	if err := h.Set(jws.TypeKey, "dpop+jwt"); err != nil {
		return "", err
	}
	if err := h.Set(jws.JWKKey, t.dpopPub); err != nil {
		return "", err
	}
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), t.dpopKey, jws.WithProtectedHeaders(h)))
	return string(signed), err
}
 
func (t *Transport) base() http.RoundTripper {
	if t.Base != nil {
		return t.Base
	}
	return http.DefaultTransport
}

Nauthera requires a DPoP nonce at /token. The first request gets 400 with use_dpop_nonce and a DPoP-Nonce header, and the transport retries once with the nonce in a new proof and a new assertion: Nauthera authenticates the client before it checks the proof, so the refused request has already used up the first assertion's jti. Every successful token response carries the next nonce, so later token requests succeed on the first try while it is valid (five minutes).

The assertion's aud is the issuer; Nauthera also accepts {issuer}/token. Its exp is one minute out, and Nauthera refuses more than 60 minutes.

The assertion key and the JWKS the client registers come from the same file:

package nauthera
 
import (
	"encoding/json"
	"net/http"
	"os"
 
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
)
 
// LoadAssertionKey reads the PEM private key and names it by its RFC 7638
// thumbprint, the kid the published JWKS carries.
func LoadAssertionKey(path string) (jwk.Key, error) {
	pem, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	key, err := jwk.ParseKey(pem, jwk.WithPEM(true))
	if err != nil {
		return nil, err
	}
	return key, jwk.AssignKeyID(key)
}
 
// JWKSHandler serves the public half of key as the JWKS the client registers
// as its jwksUri.
func JWKSHandler(key jwk.Key) (http.Handler, error) {
	pub, err := key.PublicKey()
	if err != nil {
		return nil, err
	}
	if err := pub.Set(jwk.AlgorithmKey, jwa.ES256()); err != nil {
		return nil, err
	}
	if err := pub.Set(jwk.KeyUsageKey, jwk.ForSignature); err != nil {
		return nil, err
	}
	set := jwk.NewSet()
	if err := set.AddKey(pub); err != nil {
		return nil, err
	}
	body, err := json.Marshal(set)
	if err != nil {
		return nil, err
	}
	return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "max-age=300")
		w.Write(body)
	}), nil
}
package main
 
import (
	"context"
	"io"
	"log"
	"net/http"
	"os"
 
	"example.com/billing/nauthera"
)
 
func main() {
	t := &nauthera.Transport{
		Issuer:   os.Getenv("NAUTHERA_ISSUER"),
		ClientID: os.Getenv("NAUTHERA_CLIENT_ID"),
		Resource: os.Getenv("ORDERS_API"), // https://orders.example.com/
		Scope:    "orders:read",
	}
	if err := t.NewDPoPKey(); err != nil {
		log.Fatal(err)
	}
	if path := os.Getenv("NAUTHERA_ASSERTION_KEY"); path != "" {
		key, err := nauthera.LoadAssertionKey(path) // /var/run/secrets/nauthera/key.pem
		if err != nil {
			log.Fatal(err)
		}
		t.AssertionKey = key
		jwks, err := nauthera.JWKSHandler(key)
		if err != nil {
			log.Fatal(err)
		}
		go func() { log.Fatal(http.ListenAndServe(":8081", jwks)) }()
	} else {
		t.ClientSecret = os.Getenv("NAUTHERA_CLIENT_SECRET") // local development
	}
 
	client := &http.Client{Transport: t}
	req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, t.Resource+"orders", nil)
	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	log.Printf("%s %s", res.Status, body)
}

Local development

Against the Quick Start stack the issuer is https://localhost:8443, and its certificate comes from a CA the chart created: nauthera-local-ca.crt, which step 1 saves. On Linux, point Go at it:

SSL_CERT_FILE="$PWD/nauthera-local-ca.crt" go run .

On macOS, Go reads the system trust store, so add the CA there; helm get notes nauthera -n nauthera shows how, and how to remove it again.

The local server cannot fetch a JWKS from your machine, so private_key_jwt does not work there. Register the caller with a generated secret instead, and keep DPoP, with an OIDCClient as in Quick Start step 4:

apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: nauthera
spec:
  clientId: billing-worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    generate: true
kubectl apply -f billing-worker.yaml
kubectl -n nauthera get secret billing-worker -o jsonpath='{.data.client-secret}' | base64 -d

Run the API (it listens on 8090) with NAUTHERA_ISSUER=https://localhost:8443 and NAUTHERA_AUDIENCE=http://localhost:8090/, and the caller with NAUTHERA_CLIENT_SECRET set to that secret, NAUTHERA_ASSERTION_KEY unset and ORDERS_API=http://localhost:8090/. The transport then authenticates with HTTP Basic and sends the same proofs.

Notes

  • A revoked access token stays valid until it expires (10 minutes by default, 5 for billing-worker above), because revocation does not reach access tokens. Keep access token lifetimes short.
  • Nauthera binds a token whenever the token request carries a proof, but does not require one for a client (dpop_bound_access_tokens). The API's refusal of unbound tokens is what enforces DPoP. See Tokens & Sender Constraint.
  • Do not use /introspect from the API: only the client a token was issued to can introspect it. See Connect an Application.