jwt_verify_cert intermittently returns -5 (JWT_VRFY_UNKNOWN_CERT) for valid tokens under multi-threaded load · Issue #3526 · haproxy/haproxy · GitHub
Skip to content

jwt_verify_cert intermittently returns -5 (JWT_VRFY_UNKNOWN_CERT) for valid tokens under multi-threaded load #3526

Open

Description

@CarterSmarty

Detailed Description of the Problem

With nbthread > 1 and concurrent requests, jwt_verify_cert(<alg>,<cert>) intermittently
returns -5 (JWT_VRFY_UNKNOWN_CERT) for valid tokens whose certificate is in the
certificate store with jwt on. jwt_verify(<alg>,<pubkey file>) with the same key and
tokens never fails. The rate grows with concurrency (~1% of fresh verifications at 64
concurrent connections, 2-4 threads). It never happens with one thread or with one
request in flight.

Expected Behavior

A valid token signed by a certificate loaded with jwt on always returns 1, regardless
of thread count, concurrency, or CLI activity.

Steps to Reproduce the Behavior

  1. Create key and certificate:
openssl ecparam -name prime256v1 -genkey -noout -out key.pem
openssl req -new -x509 -key key.pem -subj /CN=jwt -days 30 -out jwt.crt
openssl ec -in key.pem -pubout -out pub.pem
  1. Start haproxy with the configuration below: NBT=4 haproxy -f haproxy.cfg (or NBT=2).
  2. Send 20,000 requests, each with a freshly signed ES256 token (so nothing is cached),
    at 16-64 concurrent connections, and count the x-v response header values.
    Client (Go, stdlib only):
package main

import (
	"crypto/ecdsa"
	"crypto/rand"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"sort"
	"sync"
	"sync/atomic"
)

func main() {
	url := flag.String("url", "http://haproxy:8000/", "target")
	keyFile := flag.String("key", "key.pem", "EC private key (PEM)")
	n := flag.Int("n", 20000, "total requests")
	c := flag.Int("c", 16, "concurrent connections")
	flag.Parse()

	b, _ := os.ReadFile(*keyFile)
	blk, _ := pem.Decode(b)
	key, err := x509.ParseECPrivateKey(blk.Bytes)
	if err != nil {
		log.Fatal(err)
	}
	enc := base64.RawURLEncoding
	hdr := enc.EncodeToString([]byte(`{"alg":"ES256","typ":"JWT"}`))
	var seq atomic.Int64
	mint := func() string {
		body := hdr + "." + enc.EncodeToString([]byte(fmt.Sprintf(`{"sub":"u","jti":"%d"}`, seq.Add(1))))
		h := sha256.Sum256([]byte(body))
		r, s, _ := ecdsa.Sign(rand.Reader, key, h[:])
		sig := make([]byte, 64)
		r.FillBytes(sig[:32])
		s.FillBytes(sig[32:])
		return body + "." + enc.EncodeToString(sig)
	}

	cl := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: *c}}
	var mu sync.Mutex
	counts := map[string]int{}
	var next atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < *c; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			local := map[string]int{}
			for next.Add(1) <= int64(*n) {
				req, _ := http.NewRequest("GET", *url, nil)
				req.Header.Set("Authorization", "Bearer "+mint())
				resp, err := cl.Do(req)
				if err != nil {
					local["err"]++
					continue
				}
				resp.Body.Close()
				local[resp.Header.Get("x-v")]++
			}
			mu.Lock()
			for k, v := range local {
				counts[k] += v
			}
			mu.Unlock()
		}()
	}
	wg.Wait()
	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	fmt.Printf("n=%d c=%d", *n, *c)
	for _, k := range keys {
		fmt.Printf(" v[%s]=%d", k, counts[k])
	}
	fmt.Println()
}

Run: client -url http://<host>:8000/ -key key.pem -n 20000 -c 64
(port 8001 for the jwt_verify comparison)

Results (-5 count per 20,000 requests; all other responses were 1)

threads concurrent jwt_verify_cert jwt_verify (key file)
1 1, 4, 16, 64 0 0
2 1 0 0
2 4 52 (0.26%) -
2 16 161-162 (0.8%) 0
2 64 145-232 (0.7-1.2%) 0
4 16 164 (0.8%) 0
4 64 212 (1.1%) 0

Same behaviour when the certificate is loaded through the runtime API
(new/set/commit ssl cert, add ssl jwt) instead of crt-store. An immediate retry after
a -5 fails again ~3% of the time.

Do you have any idea what may have caused this?

3.4.6, src/jwt.c, jwt_jwsverify_rsa_ecdsa(): the x509 path (lines 417-431) takes
ckch_lock with HA_SPIN_TRYLOCK (line 419) and skips ckchs_lookup() when the lock is busy.
pubkey stays NULL, and line 448 returns JWT_VRFY_UNKNOWN_CERT (-5,
include/haproxy/jwt-t.h:82). The trylock dates from 522bca9 and was carried into
jwt_verify_cert by f5632fd. Current master has the same code.

ckch_lock is a plain HA_SPINLOCK_T (src/ssl_sock.c:676), so:

  • every worker verifying a token takes it exclusively, and two concurrent
    verifications are enough for one to fail (what the table above shows);
  • CLI commands hold it across their I/O handler and only release it in the release
    callback (e.g. ssl_ckch.c:1704 for show ssl sni, 2744 for show ssl ca-file, and
    the commit path around line 3170: "we don't unlock there, it will be unlock after
    the IO handler, in the release handler"). While such a command runs, every
    jwt_verify_cert call on every thread can return -5.

The key-file path (jwt_verify) uses jwt_cert_tree without locking and is unaffected.

History: 522bca9, which first added certificate support to jwt_verify, says the
ckch_store tree is locked only the first time a given key value is seen, and the
result is then cached in the jwt tree so later calls avoid the lookup. The current
jwt_verify_cert path (f5632fd onward) has no such cache: it calls ckchs_lookup()
under the trylock on every verification. That is presumably so runtime certificate
updates take effect, but it puts ckch_lock on the hot path.

Do you have an idea how to solve the issue?

Blocking on ckch_lock does not look acceptable, since CLI commands can hold it for
long periods. The same "trylock failure becomes an error" pattern is already flagged
in hlua.c (CertCache.set: "FIXME: this should not return an error but should come back
later"), but a converter cannot yield and come back.

Suggested direction: when a ckch_store is loaded or committed with jwt on, keep a
refcounted EVP_PKEY in a structure outside ckch_lock (e.g. in jwt_cert_tree, or a
per-store pointer swapped atomically on commit). Verification then only takes a
reference, under a read lock or RCU-style, and never touches ckch_lock. This would
also restore the original intent of 522bca9 (no ckch_store lookup per
verification) while keeping runtime updates working.
At minimum, a failed trylock should not be reported as "unknown certificate", and the
behaviour should be documented.

What is your configuration?

global
    nbthread "${NBT}"

defaults
    mode http
    timeout client 10s
    timeout connect 5s
    timeout server 10s

crt-store
    load crt "/w/jwt.crt" jwt on

frontend cert
    bind :8000
    http-request set-var(txn.v) req.hdr(authorization),word(2,' '),jwt_verify_cert("ES256","/w/jwt.crt")
    http-request return status 200 hdr x-v %[var(txn.v)]

frontend keyfile
    bind :8001
    http-request set-var(txn.v) req.hdr(authorization),word(2,' '),jwt_verify("ES256","/w/pub.pem")
    http-request return status 200 hdr x-v %[var(txn.v)]

Output of haproxy -vv

HAProxy version 3.4.6-56332c5 2026/09/28
Official Docker image haproxy:3.4
(haproxy@sha256:e273915691a8eab3f105bf3e39fd4c29a4ecf334e33421903b5589d25e14db26)
OpenSSL 3.5.7
Linux 6.8 aarch64 (Docker in a VM on Apple silicon); HAProxy pinned to 4 CPUs,
client on 3 other CPUs.

Last Outputs and Backtraces

n/a (no crash; incorrect return value)

Additional Information

Affected since 3.3.0 (jwt_verify_cert was added in f5632fd, first tag v3.3-dev10);
reproduced on 3.4.6, and current master has the same code.
Workarounds: use jwt_verify with a public-key file (no runtime key updates), or re-run
jwt_verify_cert when it returns -5. Note that matching -5 with -m int -- -5 does not
work as expected (it also matches 1); -m str -- -5 does.

Activity

  1. CarterSmarty commented on Oct 8, 2026

    @CarterSmarty
    Author

    Full haproxy -vv output:

    HAProxy version 3.4.6-56332c5 2026/09/28 - https://haproxy.org/
    Status: long-term supported branch - will stop receiving fixes around Q2 2031.
    Known bugs: http://www.haproxy.org/bugs/bugs-3.4.6.html
    Running on: Linux 6.8.0-117-generic #117-Ubuntu SMP PREEMPT_DYNAMIC Thu May  7 17:26:37 UTC 2026 aarch64
    Build options : 
      TARGET  = linux-glibc
      CC      = cc
      CFLAGS  = -O2 -g -fwrapv -fvect-cost-model=very-cheap
      OPTIONS = USE_PTHREAD_EMULATION=1 USE_GETADDRINFO=1 USE_OPENSSL=1 USE_LUA=1 USE_QUIC=1 USE_PROMEX=1 USE_PCRE2=1 USE_PCRE2_JIT=1
      DEBUG   = 
    
    Feature list : -51DEGREES +ACCEPT4 +ACME +BACKTRACE -CLOSEFROM +CPU_AFFINITY +CRYPT_H -DEVICEATLAS +DL -ECH -ENGINE +EPOLL -EVPORTS +GETADDRINFO +HAVE_TCP_MD5SIG -KQUEUE +KTLS -LIBATOMIC +LIBCRYPT +LINUX_CAP +LINUX_SPLICE +LINUX_TPROXY +LUA +MATH -MEMORY_PROFILING +NETFILTER +NS -OBSOLETE_LINKER +OPENSSL -OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE +PCRE2 +PCRE2_JIT -PCRE_JIT +POLL +PRCTL -PROCCTL +PROMEX +PTHREAD_EMULATION +QUIC -QUIC_OPENSSL_COMPAT +RT +SHM_OPEN +SLZ +SSL -STATIC_PCRE -STATIC_PCRE2 +TFO +THREAD +THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
    Detected feature list : +HAVE_WORKING_TCP_MD5SIG
    
    Default settings :
      bufsize = 16384, maxrewrite = 1024, maxpollevents = 200
    
    Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=14).
    Built with SSL library version : OpenSSL 3.5.7 9 Jun 2026
    Running on SSL library version : OpenSSL 3.5.7 9 Jun 2026
    SSL library supports TLS extensions : yes
    SSL library supports SNI : yes
    SSL library default verify directory : /usr/lib/ssl/certs
    SSL library supports : TLSv1.0 TLSv1.1 TLSv1.2 TLSv1.3
    OpenSSL providers loaded : default
    QUIC: connection sock-per-conn mode support : yes
    QUIC: GSO emission support : yes
    Built with Lua version : Lua 5.4.7
    Built with the Prometheus exporter as a service
    Built with network namespace support.
    Built with libslz for stateless compression.
    Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
    Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
    Built with PCRE2 version : 10.46 2025-08-27
    PCRE2 library supports JIT : yes
    Encrypted password support via crypt(3): yes
    Built with gcc compiler version 14.2.0
    
    Available polling systems :
          epoll : pref=300,  test result OK
           poll : pref=200,  test result OK
         select : pref=150,  test result OK
    Total: 3 (3 usable), will use epoll.
    
    Available multiplexer protocols :
    (protocols marked as <default> cannot be specified using 'proto' keyword)
           qmux : mode=HTTP  side=FE|BE  mux=QMUX  flags=HTX|NO_UPG
           quic : mode=HTTP  side=FE|BE  mux=QUIC  flags=HTX|NO_UPG|FRAMED
             h2 : mode=HTTP  side=FE|BE  mux=H2    flags=HTX|HOL_RISK|NO_UPG
      <default> : mode=HTTP  side=FE|BE  mux=H1    flags=HTX
             h1 : mode=HTTP  side=FE|BE  mux=H1    flags=HTX|NO_UPG
           fcgi : mode=HTTP  side=BE     mux=FCGI  flags=HTX|HOL_RISK|NO_UPG
      <default> : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
           spop : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
      <default> : mode=TCP   side=FE|BE  mux=PASS  flags=
           none : mode=TCP   side=FE|BE  mux=PASS  flags=NO_UPG
    
    Available services : prometheus-exporter
    Available filters :
    	[BWLIM] bwlim-in
    	[BWLIM] bwlim-out
    	[CACHE] cache
    	[COMP] comp-req
    	[COMP] comp-res
    	[COMP] compression
    	[FCGI] fcgi-app
    	[SPOE] spoe
    	[TRACE] trace
    
    
  2. wlallemand commented on Oct 9, 2026

    @wlallemand
    Member

    Indeed that doesn't look right, it should be done the same way the SNI lookup is done, having only a read lock when accessing it.

  3. rlebreton commented on Oct 9, 2026

    @rlebreton
    Contributor

    Even with a read/write lock, the CLI commands working on the ckch_stores would keep the write lock a long time so the read tries would be stuck? It might be possible to keep a read lock by default on the cli commands and promote the lock only around the ckch_store_replace call. In theory it looks like it should work but I might be missing something.

  4. wlallemand commented on Oct 9, 2026

    @wlallemand
    Member

    @rlebreton indeed, but doing it like the SNIs implies another tree, where the SSL_CTX count ref would be incremented. That would change a bit how it's done, and it would rollback a bit to how it was done with the cache tree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions