Home Posts Zero-Knowledge API Authentication [Deep Dive] 2026
Security Deep-Dive

Zero-Knowledge API Authentication [Deep Dive] 2026

Zero-Knowledge API Authentication [Deep Dive] 2026
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 13, 2026 · 10 min read

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 k such that public = [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.
Watch out: This pattern proves possession of a discrete-log secret. It is not a password-authenticated protocol. If users log in with passwords, use OPAQUE from RFC 9807 instead.

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:

  1. Register a public key derived from the client's secret scalar.
  2. Issue a short-lived, one-time challenge bound to the intended API request.
  3. 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.

Pro tip: Freeze the proof context as a versioned string or canonical CBOR blob. Versioning now saves incident time later.

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, or audience after 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 otherInfo bytes 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? +
No. A Schnorr NIZK proves possession of an application secret, but it does not replace transport protections such as mTLS. In high-trust environments, use both so the API gets channel security and proof-of-possession at the application layer.
How is this different from signing the request with Ed25519? +
A signature proves that a private key signed a message, while this pattern proves knowledge of a discrete-log secret bound to a one-time challenge. In practice, signatures are often simpler, but a ZK proof is useful when you want an explicit proof-of-knowledge flow and tighter challenge binding without transmitting a reusable signed artifact.
When should I use OPAQUE instead of this zero-knowledge API flow? +
Use OPAQUE when the client authenticates with a password rather than a provisioned secret scalar. RFC 9807 is designed for password-authenticated key exchange and protects against server-side password exposure problems that this tutorial does not address.
Can I reuse one proof for multiple API calls? +
You should not. The safe pattern is one proof per issued challenge, or one proof to mint a very short-lived token that covers a tightly scoped session. Reusing the same proof across calls weakens replay resistance and makes auditing harder.

Get Engineering Deep-Dives in Your Inbox

Weekly breakdowns of architecture, security, and developer tooling — no fluff.

Found this useful? Share it.