Skip to content

Intermittent 401 Unauthorized for valid CDP JWTs depending on HTTP client / TLS stack #132

Description

@dereckmezquita

Summary

We see intermittent 401 Unauthorized for correctly-signed CDP JWTs on the Advanced Trade REST API same key, same endpoint, same machine, with a token that validates locally and is accepted on the next attempt. Roughly 40–55% of requests fail when issued from R (via libcurl/httr2), whereas the official Python SDK is ~100% reliable and the curl CLI ~98%.

I am building an R wrapper for the API. R is a mainstream language for quantitative finance, so I would love this to work reliably and I am posting as an open question, since we cannot tell from the client side where the rejection originates.

Environment

  • GET https://api.coinbase.com/api/v3/brokerage/transaction_summary (also seen on /accounts, /key_permissions)
  • macOS (Apple Silicon), residential US IP; key created hours earlier, can_view/can_trade true
  • JWT: ES256, iss: "cdp", sub/kid = full key name, nbf = now, exp = now + 120, uri = "GET api.coinbase.com/api/v3/brokerage/transaction_summary", random nonce

Observed (same key, same machine, same minute)

Client TLS stack Success
coinbase-advanced-py (urllib3) OpenSSL ~100% (20/20, repeated)
curl CLI (libcurl) LibreSSL ~98%
R httr2 → libcurl 8.20.0 OpenSSL 3.6.2 ~55%

The 401 body is a bare Unauthorized, served via Cloudflare (cf-ray) but carrying a Coinbase trace-id, so it reaches the origin. A byte-identical token that returns 401 returns 200 on retry.

Minimal reproduction (R)

library(httr2); library(jose); library(openssl)

key_name    <- "organizations/<org>/apiKeys/<key>"
private_key <- "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n"

build_jwt <- function() {
  now <- as.integer(Sys.time())
  claim <- jwt_claim(sub = key_name, iss = "cdp", nbf = now, exp = now + 120,
                     uri = "GET api.coinbase.com/api/v3/brokerage/transaction_summary")
  jwt_encode_sig(claim, read_key(private_key),
    header = list(kid = key_name,
                  nonce = paste(sample(c(0:9, letters[1:6]), 64, TRUE), collapse = "")))
}

for (i in 1:20) {
  resp <- request("https://api.coinbase.com/api/v3/brokerage/transaction_summary") |>
    req_headers(Authorization = paste("Bearer", build_jwt())) |>
    req_error(is_error = function(r) FALSE) |> req_perform()
  cat(i, resp_status(resp), "\n")   # mix of 200 and 401, same valid token shape
}

The same loop via the curl CLI (fresh JWT per call) is far more reliable, though it still 401s occasionally.

Already ruled out

Token validity (self-verifies; works via curl); request rate/spacing (3–5 s jittered spacing, controlled A/B no difference); JWT claims and uri formatting; nonce, User-Agent, Accept-Encoding; HTTP/1.1 vs HTTP/2; connection reuse vs fresh connections; persistent session vs fresh process per request; TLS 1.2 vs 1.3; libcurl version (8.7.1 vs 8.20.0); TLS backend (SecureTransport vs OpenSSL).

The only consistent predictor of reliability is which HTTP client / TLS stack issues the request.

Questions

  1. Does the edge/auth layer apply any client- or TLS-fingerprint-based filtering that might reject valid JWTs from some libcurl clients?
  2. Could this be a throttle returning 401 instead of 429 (cf. get_candles throttling returns "401 Unauthorized" instead of "Too Many Requests" #60)? Our spacing test suggests not.
  3. What is the recommended way to call the Advanced Trade API reliably from non-Python clients?
  4. Is there possibly an issue at some other layer cloudflare etc?

Happy to provide packet captures or JA3/JA4 fingerprints. Thank you!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions