The Problem Statement: Global Auth & CORS Interception
ESPAsyncWebServer. You need to enforce Bearer token authentication and inject CORS (Cross-Origin Resource Sharing) headers on every single route. However, you must not block the async event loop, you must correctly handle browser CORS preflight (OPTIONS) requests, and you must not break WebSocket upgrade handshakes. How do you architect this globally without duplicating code across 20 different route handlers?
In embedded web servers, copying and pasting authentication logic into every server.on() callback is a maintenance nightmare and bloats your firmware footprint. We need a middleware pattern. This walkthrough breaks down the exact architecture, code, and verification steps to implement a robust global filter.
Method Selection & The Async Stack Trap
Which method applies and why? The correct architectural pattern here is Request Interception via the server.addFilter() method. Unlike traditional synchronous servers (like the default WebServer.h) where middleware wraps the routing table, ESPAsyncWebServer evaluates filters before route matching. If the filter returns true, the request proceeds to the router. If it returns false, the request pipeline halts immediately.
request->send() inside the filter to reject a request, but then returning true. If you send a response and return true, the async engine will attempt to match the request to a route, fail, and trigger a watchdog reset or a null pointer exception in the AsyncTCP stack. Furthermore, failing to explicitly pass OPTIONS requests will cause modern browsers to block your API due to CORS preflight failures.
For ESP32 Arduino Core 3.x (the standard in 2026), you must use the actively maintained mathieucarbou/ESPAsyncWebServer fork, as the original me-no-dev repository is incompatible with the new network stack.
Step-by-Step Solution & Code Walkthrough
Here is the complete, compilable implementation. Every logical step is mapped out below the code.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
const char* ssid = "YourNetwork";
const char* password = "YourPassword";
const char* VALID_TOKEN = "super-secret-esp32-token";
AsyncWebServer server(80);
// --- THE GLOBAL FILTER ---
bool globalAuthAndCorsFilter(AsyncWebServerRequest *request) {
// Step 1: Inject CORS headers on ALL requests
request->addInterestingHeader("ANY");
// Step 2: Handle CORS Preflight (OPTIONS) immediately
if (request->method() == HTTP_OPTIONS) {
AsyncWebServerResponse *response = request->beginResponse(204);
response->addHeader("Access-Control-Allow-Origin", "*");
response->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
response->addHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
request->send(response);
return false; // Halt pipeline, response already sent
}
// Step 3: Allow WebSocket Upgrades to bypass HTTP auth
if (request->url() == "/ws" || request->hasHeader("Upgrade")) {
return true; // Pass to router/WebSocket handler
}
// Step 4: Enforce Bearer Token Auth
if (!request->hasHeader("Authorization")) {
request->send(401, "application/json", "{\"error\":\"Missing Auth\"}");
return false; // Halt pipeline
}
String authHeader = request->getHeader("Authorization")->value();
if (authHeader != String("Bearer ") + VALID_TOKEN) {
request->send(403, "application/json", "{\"error\":\"Invalid Token\"}");
return false; // Halt pipeline
}
// Step 5: Auth passed, inject CORS header for the actual response
// Note: We add it to the request context so route handlers can use it
return true; // Proceed to route matching
}
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) { delay(500); }
Serial.println(WiFi.localIP());
// Attach the filter globally
server.addFilter(globalAuthAndCorsFilter);
// Route Handlers
server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *request){
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", "{\"temp\":22.5}");
response->addHeader("Access-Control-Allow-Origin", "*");
request->send(response);
});
server.begin();
}
void loop() {
// Async server requires empty loop
}
Execution Logic Breakdown
- Header Registration:
addInterestingHeader("ANY")forces the async parser to retain all headers in RAM. By default, AsyncWebServer drops headers it doesn't recognize to save heap. If you skip this,hasHeader("Authorization")will always return false. - Preflight Interception: Browsers send an
OPTIONSrequest before aPOST/PUT. We intercept it, return a 204 No Content with the required CORS headers, and returnfalseto stop processing. - WebSocket Bypass: WebSockets use an HTTP Upgrade handshake. If we enforce standard Bearer auth here, the JS WebSocket API (which cannot send custom headers during the handshake) will fail. We pass it to the router.
- Auth Validation & Rejection: If the token fails, we send the 401/403 response and must return
false.
Sanity Check & Independent Verification
How do you verify this answer independently without relying on the serial monitor alone? We evaluate the system across three metrics: execution time, heap stability, and network protocol compliance.
| Metric | Expected Value | Verification Tool |
|---|---|---|
| Execution Time (Order of Magnitude) | 10^-5 seconds (10-50 µs) | Wrap filter in micros() timestamps |
| Heap Delta (Units: Bytes) | 0 bytes over 10,000 requests | ESP.getFreeHeap() logging |
| CORS Preflight Status | HTTP 204 with Allow-Headers | curl -X OPTIONS -v |
String objects that aren't destroyed when the request drops. To verify independently, run a bash loop sending 5,000 unauthorized requests via curl. If ESP.getFreeHeap() drops by even 4 bytes per request, you have a fragmentation leak in your header parsing logic.
Frequently Asked Questions
How do I apply a global filter to only specific routes in ESPAsyncWebServer?
You cannot scope server.addFilter() to specific URLs natively; it applies to the entire server instance. To achieve route-specific filtering, you have two options: (1) Use a single global filter that checks request->url().startsWith("/api/") to conditionally apply logic, or (2) abandon the global filter and use server.on("/route", HTTP_GET, handler, nullptr, authMiddleware) if your specific fork supports the 5-parameter route definition. For most 2026 ESP32 projects, the URL string check inside the global filter is the most memory-efficient approach.
Why does my ESP32 crash when I use request->send() inside an async filter?
This crash is almost always caused by returning true after calling request->send(). When a filter returns true, the AsyncWebServer engine assumes the request is still valid and attempts to match it against registered routes. When it fails to find a match, it attempts to send a default 404 response. Calling send() twice on the same AsyncWebServerRequest pointer triggers a panic in the AsyncTCP layer, resulting in a Guru Meditation Error (LoadProhibited). Always return false if you generate a response inside the filter.
Can I use ESPAsyncWebServer global filters with ESP32 Arduino Core 3.0 and 3.1?
Yes, but you must use the correct library fork. The legacy me-no-dev/ESPAsyncWebServer relies on deprecated network event callbacks that were removed in Arduino Core 3.0. You must uninstall the legacy version and install the mathieucarbou/ESPAsyncWebServer fork via the Arduino Library Manager or PlatformIO. This fork fully supports the new Network.h API and maintains compatibility with the addFilter() middleware pattern demonstrated above.






