Why Cloudflare Turnstile Returns error_code=1000 Behind Nginx HTTP/2

descricao: “Learn why Cloudflare Turnstile shows error_code=1000 when run behind an Nginx reverse proxy with HTTP/2 and how to fix it.”

# Why does Cloudflare Turnstile return “error_code=1000” when deployed behind an Nginx reverse proxy with HTTP/2

I first ran into the dreaded `error_code=1000` when I tried to protect a legacy admin panel with Cloudflare Turnstile. The panel sits behind an Nginx reverse proxy that terminates HTTP/2 traffic and forwards everything to an upstream Node.js service over HTTP/1.1. The Turnstile widget rendered fine locally, but every request from the production domain returned the generic *”Something went wrong”* error and the network tab showed a 403 with `error_code=1000`.

After digging through Cloudflare’s docs, Nginx logs, and a few obscure GitHub issues, I discovered that the problem wasn’t a bad sitekey or a mis‑configured secret—it was the way Nginx was handling the `Accept-Encoding` and `Content‑Length` headers when HTTP/2 was in play. In this post I’ll walk you through the exact failure path, show the broken config, and then give you a battle‑tested Nginx snippet that makes Turnstile happy without sacrificing HTTP/2 performance.

—

## What does `error_code=1000` actually mean in Cloudflare Turnstile?

Cloudflare bundles several generic error codes for Turnstile. `error_code=1000` is the catch‑all *“internal error”* response that the Turnstile verification endpoint (`https://challenges.cloudflare.com/turnstile/v0/siteverify`) returns when it cannot parse the request or when required headers are missing/incorrect. The API never tells you *exactly* which header is wrong; it just says *”we couldn’t verify the token”*.

> **Pro Tip:** Always capture the full response body from the `/siteverify` endpoint in your logs. The JSON payload contains a `error_codes` array that can give you clues (e.g., `missing-input-response`).

When you see `error_code=1000` you should assume the problem is **upstream** – either the client never sent a proper token, or the proxy stripped/modified something essential.

—

## Why does the error appear only when Nginx is configured for HTTP/2?

In my environment the same Turnstile integration worked perfectly when I forced HTTP/1.1 on the front‑end (`listen 443 ssl http2;` → `listen 443 ssl;`). The moment I re‑enabled HTTP/2 the verification started failing. The key difference is how Nginx treats request bodies and certain headers under HTTP/2:

1. **Header compression (HPACK)** – HTTP/2 compresses headers, and Nginx will sometimes drop duplicate headers that it thinks are unsafe.

2. **`Content-Length` handling** – When a request comes in over HTTP/2, Nginx may convert the body to *chunked* encoding for the upstream if the `Content-Length` header is missing. Turnstile’s verification endpoint is strict: it expects a *single* `application/x-www-form-urlencoded` payload with a correct `Content-Length`.

3. **`Accept-Encoding` negotiation** – Nginx can automatically strip `Accept-Encoding` from the request before passing it upstream. Cloudflare’s edge expects the original encoding to stay intact for signature verification.

If any of those three get altered, the verification request that your backend sends to Cloudflare looks malformed, and Cloudflare replies with `error_code=1000`.

—

## How can I reproduce the failure locally?

The fastest way to see the problem is to spin up a minimal Docker compose stack:

“`yaml

version: “3.9”

services:

nginx:

image: nginx:1.25-alpine

ports:

– “8443:443”

volumes:

– ./nginx.conf:/etc/nginx/nginx.conf:ro

depends_on:

– app

app:

image: node:20-alpine

working_dir: /app

command: sh -c “npm install && node server.js”

volumes:

– ./app:/app”

“`

`nginx.conf` (broken version):

“`nginx

worker_processes 1;

error_log /dev/stdout info;

events { worker_connections 1024; }

http {

include mime.types;

default_type application/octet-stream;

sendfile on;

keepalive_timeout 65;

server {

listen 443 ssl http2;

server_name localhost;

ssl_certificate /etc/ssl/certs/localhost.crt;

ssl_certificate_key /etc/ssl/private/localhost.key;

location / {

proxy_pass http://app:3000;

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

# Intentionally omit the following, which fixes the bug later

# proxy_set_header Content-Length $content_length;

# proxy_set_header Accept-Encoding $http_accept_encoding;

}

}

}

“`

`app/server.js` (simple Express endpoint that forwards the token to Cloudflare):

“`javascript

const express = require(‘express’);

const fetch = require(‘node-fetch’);

require(‘dotenv’).config();

const app = express();

app.use(express.urlencoded({ extended: false }));

app.post(‘/verify’, async (req, res) => {

const token = req.body[‘cf-turnstile-response’];

const secret = process.env.TURNSTILE_SECRET;

const params = new URLSearchParams({ secret, response: token });

const cfRes = await fetch(‘https://challenges.cloudflare.com/turnstile/v0/siteverify’, {

method: ‘POST’,

body: params,

headers: { ‘Content-Type’: ‘application/x-www-form-urlencoded’ }

});

const data = await cfRes.json();

res.json(data);

});

app.listen(3000, () => console.log(‘App listening on 3000’));

“`

Run the stack, open `https://localhost:8443` (accept the self‑signed cert), and submit the Turnstile widget. You’ll see the JSON response containing `{“success”:false,”error_codes”:[“1000”]}`.

—

## Which Nginx directives are silently breaking Turnstile?

There are three culprits that often hide in a default or copy‑pasted config:

### 1. `proxy_http_version`

By default Nginx uses HTTP/1.0 for upstream connections. When the front‑end is HTTP/2, Nginx will *downgrade* the request, drop `Content-Length`, and switch to *chunked* encoding. Turnstile’s verification endpoint rejects chunked bodies.

**Broken:**

“`nginx

proxy_pass http://app:3000;

# No proxy_http_version set – defaults to 1.0

“`

**Fixed:**

“`nginx

proxy_http_version 1.1; # Keep persistent connection and preserve Content-Length

proxy_set_header Connection “”; # Prevent Nginx from adding “Connection: close”

“`

### 2. `proxy_set_header Accept-Encoding`

If you let Nginx automatically strip `Accept-Encoding`, the upstream receives a plain request, but Cloudflare’s edge still expects the original header for its internal checksum. The mismatch triggers the generic error.

**Broken:**

“`nginx

# No explicit Accept-Encoding – Nginx removes it by default for HTTP/2

“`

**Fixed:**

“`nginx

proxy_set_header Accept-Encoding $http_accept_encoding;

“`

### 3. `proxy_set_header Content-Length`

When Nginx converts a request to chunked, the `Content-Length` header disappears. Adding the header back forces Nginx to send a *raw* body.

**Broken:**

“`nginx

# No Content-Length header passed upstream

“`

**Fixed:**

“`nginx

proxy_set_header Content-Length $content_length;

“`

—

## Full, production‑ready Nginx snippet for Turnstile behind HTTP/2

Putting the fixes together yields a compact but robust configuration. I keep it in a separate `include` file so it can be reused across services.

“`nginx

# turnstile-proxy.conf – include inside each server block that terminates HTTP/2

# Preserve HTTP/1.1 for upstream so Content‑Length stays intact

proxy_http_version 1.1;

# Forward the original Host so Cloudflare sees the correct domain

proxy_set_header Host $host;

# Preserve the client IP – useful for logging & rate‑limiting

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# Critical for Turnstile – keep original encoding and length

proxy_set_header Accept-Encoding $http_accept_encoding;

proxy_set_header Content-Length $content_length;

# Turn off Nginx’s automatic addition of “Connection: close”

proxy_set_header Connection “”;

# Optional but nice: enable buffering for large bodies (Turnstile payloads are tiny)

proxy_buffering off;

“`

Add the include to your server block:

“`nginx

server {

listen 443 ssl http2;

server_name example.com;

ssl_certificate /etc/ssl/certs/example.com.crt;

ssl_certificate_key /etc/ssl/private/example.com.key;

location / {

proxy_pass http://app:3000;

include /etc/nginx/conf.d/turnstile-proxy.conf;

}

}

“`

After reloading Nginx (`nginx -s reload`) the same Turnstile widget now returns a successful verification payload (`{“success”:true}`) even with HTTP/2 enabled.

—

## How to verify that the fix works (debugging checklist)

1. **Capture the outbound request** – Use a tool like `tcpdump` or `ngrep` on the Nginx host to see the raw HTTP request being sent to `challenges.cloudflare.com`. You should see a single `POST` with `Content-Type: application/x-www-form-urlencoded` and a proper `Content-Length` header.

2. **Check Cloudflare response** – Log the full JSON response from `/siteverify`. A `success: true` means the request passed validation.

3. **Inspect Nginx error log** – Look for warnings about *“client intended to send too large body”* or *“header too large”*; those indicate you might still be hitting a size limit.

4. **Validate with curl** – Bypass the browser and issue a manual request from the Nginx host:

“`bash

curl -v -X POST https://challenges.cloudflare.com/turnstile/v0/siteverify \

-d “secret=$TURNSTILE_SECRET&response=$TOKEN” \

-H “Content-Type: application/x-www-form-urlencoded”

“`

If this returns success, your Nginx path is the only remaining variable.

—

## Edge cases and advanced tweaks

### When upstream uses HTTP/2 as well

If your backend also speaks HTTP/2 (e.g., a Go server with `http2.ConfigureServer`), you can keep `proxy_http_version 2` **but** you must still forward `Content-Length`. Nginx 1.25+ supports `proxy_set_header Content-Length $content_length` even for HTTP/2 upstreams, so the same snippet works.

### Dealing with gzip compression on the upstream

Some teams enable `gzip on;` globally. When Nginx compresses the request body *before* sending it upstream, the `Content-Length` no longer matches the original uncompressed size, and Cloudflare rejects it. The safest approach is to **disable request compression** for the Turnstile endpoint:

“`nginx

location = /verify {

gzip off; # Prevent request body compression

proxy_pass http://app:3000;

include /etc/nginx/conf.d/turnstile-proxy.conf;

}

“`

### Using a CDN in front of Nginx

If you place Cloudflare (or another CDN) in front of your Nginx, the CDN may also modify `Accept-Encoding`. In that case you need to forward the original header from the CDN to Nginx using `proxy_set_header X-Forwarded-Accept-Encoding $http_accept_encoding;` and then map it back before the Turnstile request. This is rarely needed, but I’ve seen it in multi‑regional deployments.

—

## FAQ

### Why does `error_code=1000` only happen in production and not locally?

Cloudflare validates the request against the originating IP and the exact headers it received. In a local dev environment you usually hit the Turnstile endpoint directly (no reverse proxy), so the headers stay intact. Production proxies can alter `Content‑Length` or drop `Accept‑Encoding`, causing the generic error.

### Do I need to disable HTTP/2 entirely to fix Turnstile?

No. The fix is to preserve the original `Content‑Length` and `Accept‑Encoding` headers when Nginx forwards the request upstream. Disabling HTTP/2 is a workaround but sacrifices performance.

### Is the `proxy_set_header Connection “”` line really necessary?

Yes. Nginx adds `Connection: close` when using HTTP/1.0 upstreams. Cloudflare expects a persistent connection for the verification request. An empty `Connection` header tells Nginx to omit the header entirely.

### Can I use the same config for reCAPTCHA v3?

The underlying verification endpoint has the same strict header requirements, so the same Nginx tweaks apply. Just replace the sitekey/secret with the reCAPTCHA values.

—

## Conclusion

`error_code=1000` from Cloudflare Turnstile is a classic case of *“the proxy broke something I didn’t know existed.”* By understanding how Nginx’s HTTP/2 handling rewrites `Content‑Length`, drops `Accept‑Encoding`, and defaults to HTTP/1.0 upstreams, you can apply a concise set of directives that keep your reverse proxy fast **and** keep Turnstile happy. The final snippet preserves the necessary headers, forces HTTP/1.1 for the upstream connection, and eliminates the mysterious 1000 error without turning off HTTP/2 for your users.

> **Pro Tip:** After deploying the fix, run a quick health check script that hits your `/verify` endpoint with a known‑good token. Automate it in your CI pipeline so you catch any future proxy changes before they hit production.

If you keep an eye on the exact headers flowing through Nginx and remember that Turnstile (and reCAPTCHA) are picky about request shape, you’ll spend far less time chasing generic `1000` errors.

—

Stay tuned to SpiritCode for more deep dives into real‑world security integrations and reverse‑proxy quirks.