Zero-Knowledge API Authentication [Deep Dive] 2026
Bottom Line
For API authentication, a challenge-bound Schnorr proof over Ristretto255 is a practical zero-knowledge pattern that keeps the secret off the wire. In high-trust environments, pair it with one-time nonces, short-lived tokens, mTLS, and audit-grade logging.
Key Takeaways
- ›Use a Schnorr NIZK over Ristretto255 for compact proof-of-possession logins.
- ›Bind every proof to nonce, audience, method, path, and timestamp to block replay.
- ›Burn each challenge after one verification and keep issued tokens under 5 minutes.
- ›If humans authenticate with passwords, move to OPAQUE rather than extending this pattern.
Zero-knowledge proofs are no longer exotic research toys. In 2026, they are a practical way to authenticate high-trust API clients without ever transmitting the underlying secret. This tutorial shows a compact pattern based on a Schnorr NIZK proof over Ristretto255, implemented in Go with Cloudflare CIRCL. The result is a challenge-bound proof-of-possession flow that fits service-to-service APIs, regulated environments, and device identities.
- Use a Schnorr NIZK when the client already has a long-lived secret and the server only needs proof of possession.
- Bind the proof to nonce, audience, HTTP method, path, and issued time.
- Delete the challenge after the first verify attempt to shut down replay windows.
- Keep the returned token short-lived, or skip tokens and re-prove per request for stricter trust zones.
Why This Pattern Fits High-Trust APIs
Bottom Line
A challenge-bound Schnorr proof gives you zero-knowledge proof of secret possession without putting the secret, password, or private key on the wire. It is leaner than a full zk-SNARK stack and much easier to audit for API authentication.
This tutorial uses the proof style described in RFC 8235, a prime-order group interface from CIRCL, and Ristretto255, which is a clean fit for Schnorr-style protocols. If your goal is API authentication rather than private computation, this is usually the right level of complexity.
- What the client proves: it knows a secret scalar
ksuch thatpublic = [k]G. - What the server stores: only the public element and key metadata.
- What crosses the network: a one-time challenge, the proof, and request context.
- What does not cross the network: the secret scalar itself.
Prerequisites
Prerequisites Box
- Go with modules enabled.
- A trust model where the client can keep a long-lived secret in an HSM, TPM, secure enclave, or hardened service config.
- mTLS or an equivalent transport control for production. The proof should strengthen authentication, not replace transport security.
- A replay cache or fast key-value store for one-time challenges.
- Structured audit logs that record
key_id, challenge issue time, verify result, and token issue result.
Before replaying real request shapes in staging, redact payload fragments and headers with the Data Masking Tool. For a pattern like this, log hygiene is part of the security model, not documentation cleanup.
go mod init zkapi-demo
go get github.com/cloudflare/circl@latest
Implement the Proof Flow
The flow has three numbered steps:
- Register a public key derived from the client's secret scalar.
- Issue a short-lived, one-time challenge bound to the intended API request.
- Have the client generate a Schnorr NIZK over that context and verify it before minting a short-lived token.
Step 1: Build a minimal end-to-end demo
The following single-file demo runs a verifier and a demo client in one process so you can inspect the entire handshake. In production, the secret stays client-side only.
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/cloudflare/circl/group"
"github.com/cloudflare/circl/zk/dl"
)
var g = group.Ristretto255
type challengeRequest struct {
KeyID string `json:"key_id"`
Audience string `json:"audience"`
Method string `json:"method"`
Path string `json:"path"`
}
type challengeResponse struct {
Nonce string `json:"nonce"`
IssuedAt string `json:"issued_at"`
ExpiresIn int `json:"expires_in"`
}
type challengeRecord struct {
KeyID string
Audience string
Method string
Path string
IssuedAt string
ExpiresAt time.Time
}
type proofWire struct {
V string `json:"v"`
R string `json:"r"`
}
type authRequest struct {
KeyID string `json:"key_id"`
Audience string `json:"audience"`
Method string `json:"method"`
Path string `json:"path"`
Nonce string `json:"nonce"`
IssuedAt string `json:"issued_at"`
Proof proofWire `json:"proof"`
}
type authResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
var (
publicKeys = map[string]string{}
challenges = map[string]challengeRecord{}
mu sync.Mutex
)
func main() {
secret := g.RandomNonZeroScalar(rand.Reader)
public := g.NewElement().MulGen(secret)
publicKeys["device-7"] = mustEncodeElement(public)
mux := http.NewServeMux()
mux.HandleFunc("/zk/challenge", issueChallenge)
mux.HandleFunc("/zk/verify", verifyProof)
go func() {
log.Fatal(http.ListenAndServe(":8080", mux))
}()
time.Sleep(150 * time.Millisecond)
challReq := challengeRequest{
KeyID: "device-7",
Audience: "ledger-api",
Method: "POST",
Path: "/v1/transfers",
}
var challResp challengeResponse
mustPostJSON("http://127.0.0.1:8080/zk/challenge", challReq, &challResp)
ctx := contextBytes(challReq.Audience, challReq.Method, challReq.Path, challResp.Nonce, challResp.IssuedAt)
proof := dl.Prove(g, g.Generator(), public, secret, []byte(challReq.KeyID), ctx, rand.Reader)
authReq := authRequest{
KeyID: challReq.KeyID,
Audience: challReq.Audience,
Method: challReq.Method,
Path: challReq.Path,
Nonce: challResp.Nonce,
IssuedAt: challResp.IssuedAt,
Proof: proofWire{
V: mustEncodeElement(proof.V),
R: mustEncodeScalar(proof.R),
},
}
var authResp authResponse
mustPostJSON("http://127.0.0.1:8080/zk/verify", authReq, &authResp)
fmt.Printf("challenge: nonce=%s expires_in=%d\n", challResp.Nonce, challResp.ExpiresIn)
fmt.Printf("verify: token_type=%s expires_in=%d access_token=%s\n", authResp.TokenType, authResp.ExpiresIn, authResp.AccessToken)
}
func issueChallenge(w http.ResponseWriter, r *http.Request) {
var req challengeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, ok := publicKeys[req.KeyID]; !ok {
http.Error(w, "unknown key_id", http.StatusNotFound)
return
}
resp := challengeResponse{
Nonce: randomToken(24),
IssuedAt: time.Now().UTC().Format(time.RFC3339Nano),
ExpiresIn: 60,
}
mu.Lock()
challenges[resp.Nonce] = challengeRecord{
KeyID: req.KeyID,
Audience: req.Audience,
Method: req.Method,
Path: req.Path,
IssuedAt: resp.IssuedAt,
ExpiresAt: time.Now().UTC().Add(60 * time.Second),
}
mu.Unlock()
writeJSON(w, resp)
}
func verifyProof(w http.ResponseWriter, r *http.Request) {
var req authRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mu.Lock()
rec, ok := challenges[req.Nonce]
if ok {
delete(challenges, req.Nonce)
}
mu.Unlock()
if !ok || time.Now().UTC().After(rec.ExpiresAt) {
http.Error(w, "expired or missing challenge", http.StatusUnauthorized)
return
}
if rec.KeyID != req.KeyID || rec.Audience != req.Audience || rec.Method != req.Method || rec.Path != req.Path || rec.IssuedAt != req.IssuedAt {
http.Error(w, "challenge binding mismatch", http.StatusUnauthorized)
return
}
public, err := decodeElement(publicKeys[req.KeyID])
if err != nil {
http.Error(w, "bad public key", http.StatusInternalServerError)
return
}
v, err := decodeElement(req.Proof.V)
if err != nil {
http.Error(w, "bad proof element", http.StatusBadRequest)
return
}
rs, err := decodeScalar(req.Proof.R)
if err != nil {
http.Error(w, "bad proof scalar", http.StatusBadRequest)
return
}
proof := dl.Proof{V: v, R: rs}
ctx := contextBytes(req.Audience, req.Method, req.Path, req.Nonce, req.IssuedAt)
if !dl.Verify(g, g.Generator(), public, proof, []byte(req.KeyID), ctx) {
http.Error(w, "proof verification failed", http.StatusUnauthorized)
return
}
writeJSON(w, authResponse{
AccessToken: randomToken(32),
TokenType: "Bearer",
ExpiresIn: 300,
})
}
func contextBytes(audience, method, path, nonce, issuedAt string) []byte {
return []byte(fmt.Sprintf(
"tb-zk-auth:v1|aud=%s|method=%s|path=%s|nonce=%s|issued_at=%s",
audience, method, path, nonce, issuedAt,
))
}
func mustEncodeElement(e group.Element) string {
b, err := e.MarshalBinary()
if err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func mustEncodeScalar(s group.Scalar) string {
b, err := s.MarshalBinary()
if err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func decodeElement(h string) (group.Element, error) {
b, err := hex.DecodeString(h)
if err != nil {
return nil, err
}
e := g.NewElement()
return e, e.UnmarshalBinary(b)
}
func decodeScalar(h string) (group.Scalar, error) {
b, err := hex.DecodeString(h)
if err != nil {
return nil, err
}
s := g.NewScalar()
return s, s.UnmarshalBinary(b)
}
func mustPostJSON(url string, payload any, out any) {
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("%s: %s", resp.Status, string(b)))
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
panic(err)
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func randomToken(size int) string {
raw := make([]byte, size)
if _, err := rand.Read(raw); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
Step 2: Understand what the verifier actually checks
- The public key maps to the expected
key_id. - The challenge exists, is not expired, and is deleted after lookup.
- The request context exactly matches the challenge record.
- The proof verifies against the stored public key,
userID, and context bytes.
The otherInfo input is where most teams either get replay resistance right or accidentally lose it. Keep it canonical. Do not let multiple serializers produce semantically equivalent but byte-different payloads.
Verify the Handshake
Run the demo with go run .. A successful end-to-end exchange should print a challenge and a short-lived bearer token.
challenge: nonce=JYvW3b2f3k6g6zvS3Qy3m2m7f7jA2k5D expires_in=60
verify: token_type=Bearer expires_in=300 access_token=1nL0m4fM8m9a4mQYV9mJ8oY8Sxk2nH2mL4i1Aq0mAqM
Expected output checklist
- The challenge TTL is short, typically 60 seconds or less.
- The verify response returns 200 OK and a token TTL under 5 minutes.
- Reusing the same challenge produces 401 Unauthorized.
- Changing
method,path, oraudienceafter challenge issuance also produces 401 Unauthorized.
Once this works locally, move the secret scalar out of the demo process. For real deployments, the client should generate proofs inside an HSM, TPM-backed service, or platform secure enclave.
Troubleshooting Top 3
1. Proof verifies locally but fails on the server
- Your
otherInfobytes do not match exactly between prover and verifier. - You changed field order, timestamp precision, or path normalization.
- Fix it by defining one canonical context builder and reusing it in both code paths.
2. Replay protection looks correct, but duplicate requests still succeed
- The challenge is being deleted after verification instead of before or atomically with lookup.
- A distributed verifier fleet is using local memory instead of shared challenge state.
- Fix it by moving challenge storage to Redis or another atomic store with per-key TTL.
3. Cross-language clients cannot decode the proof
- One side is hex-encoding compressed bytes and the other expects a different element format.
- The scalar or element length is being trimmed by a generic serializer.
- Fix it by pinning one encoding format, one byte length, and one test vector per language binding.
What's Next
Once the base flow is stable, raise the assurance level deliberately instead of bolting on random features.
- Add mTLS so the proof is one control in a layered channel, not the entire trust boundary.
- Issue proof-derived session tokens with audience scoping and sub-5-minute TTLs.
- Move key registration behind attestation if the client is hardware-backed.
- If you need password-based authentication, switch to OPAQUE from RFC 9807 instead of repurposing this discrete-log flow.
- If you publish code samples internally, run them through the Code Formatter so copy-paste drift does not become a support issue.
The practical takeaway is simple: for API authentication, you usually do not need a general-purpose proving system. A challenge-bound Schnorr NIZK on a prime-order group gives you the core zero-knowledge property you want, with a verification path your security team can actually reason about.
Frequently Asked Questions
Is a Schnorr proof enough to replace TLS client certificates? +
How is this different from signing the request with Ed25519? +
When should I use OPAQUE instead of this zero-knowledge API flow? +
Can I reuse one proof for multiple API calls? +
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.