yum upgrades for production use, this is the repository for you.
Active subscription is required.
Bots account for a staggering portion of web traffic. Some estimates put it at nearly half of all requests hitting your server. While services like Cloudflare offer JavaScript challenge pages to filter automated traffic, not everyone wants to route through a third-party CDN.
What if you could get the same bot-filtering capability directly in NGINX, with virtually no overhead?
That is exactly what the NGINX JavaScript challenge module does. It presents visitors with a transparent proof-of-work challenge. Legitimate browsers solve it in seconds. Bots and scripts that cannot execute JavaScript are stopped cold.
How the NGINX JavaScript Challenge Works
The module operates on a simple but effective principle: real browsers can execute JavaScript, most bots cannot.
When a visitor first arrives, the module intercepts the request. It returns an HTML page with a JavaScript-based SHA-1 proof-of-work puzzle. The browser solves this puzzle, sets a verification cookie, and reloads the page. The process takes just a few seconds. After that, the visitor browses normally.
Here is the step-by-step flow:
- Initial request — The visitor hits your server without a verification cookie
- Challenge page — NGINX returns an HTTP 503 response with an embedded JavaScript challenge
- Proof-of-work — The browser computes a SHA-1 hash that satisfies specific conditions
- Cookie set — Once solved, JavaScript sets a
rescookie containing the solution - Page reload — The browser automatically reloads after 3 seconds
- Verification — NGINX validates the cookie against the expected challenge
- Normal browsing — Subsequent requests pass through without any challenge
The Proof-of-Work Algorithm
The challenge is generated server-side as:
challenge = SHA1(time_bucket + client_IP + secret)
The time_bucket groups requests into configurable time windows (default: 1 hour). The client must find a nonce such that SHA1(challenge + nonce) produces specific byte values at a computed offset. This is trivial for a browser (solved in under a second) but impossible for a bot that does not execute JavaScript.
Why HTTP 503?
The module returns 503 Service Temporarily Unavailable for the challenge page. Search engines understand 503 as a temporary condition and will retry later. It also signals to monitoring tools that the response is not normal content.
However, keep in mind that 503 is not invisible to search engines. If Googlebot encounters 503 responses repeatedly on your public pages, it may reduce crawl frequency or temporarily drop those pages from search results. While a single 503 will not cause permanent damage, sustained 503 responses on pages you want indexed is not ideal.
For this reason, the NGINX JavaScript challenge module is best suited for non-public areas such as admin panels, login pages, API endpoints, and other paths that do not need to be indexed. If you do enable it site-wide, make sure to bypass the challenge for verified search engine crawlers using the bot verification module.
Installing the NGINX JavaScript Challenge Module
The module is available as a pre-built package from the GetPageSpeed RPM repository. It supports RHEL-based distributions including Rocky Linux, AlmaLinux, CentOS, and Amazon Linux.
Enable the GetPageSpeed Repository
sudo dnf install -y https://extras.getpagespeed.com/release-latest.rpm
Install the Module
sudo dnf install -y nginx-module-js-challenge
Load the Module
Add the following line at the very top of /etc/nginx/nginx.conf, before any other blocks:
load_module modules/ngx_http_js_challenge_module.so;
Verify the configuration is valid:
sudo nginx -t
You should see:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Then reload NGINX to activate the module:
sudo systemctl reload nginx
Configuration Directives
The module provides five directives. All work in server and location contexts. The js_challenge toggle also works inside if blocks.
js_challenge
Enables or disables the JavaScript challenge for the current context.
Syntax: js_challenge on | off;
Default: off
Context: server, location, if
server {
js_challenge on;
}
js_challenge_secret
Sets the shared secret for generating challenge hashes. You must change this from the default. Each server or location can use a different secret.
Syntax: js_challenge_secret <string>;
Default: changeme
Context: server, location
server {
js_challenge_secret "my-unique-secret-key-2024";
}
Use a long, random string. Generate one with:
openssl rand -hex 32
js_challenge_bucket_duration
Controls how long a challenge stays valid, in seconds. The module groups time into buckets. All requests within a bucket share the same challenge for a given client IP. When the bucket expires, visitors solve a new challenge.
Syntax: js_challenge_bucket_duration <seconds>;
Default: 3600 (1 hour)
Context: server, location
server {
js_challenge_bucket_duration 1800; # New challenge every 30 minutes
}
Lower values increase security but require more frequent re-solving. The minimum value is 1.
js_challenge_html
Specifies a custom HTML file for the challenge page body. This lets you brand the interstitial page. The file is read at NGINX startup, so changes require a reload.
Syntax: js_challenge_html <path>;
Default: A generic message prompting you to set this directive
Context: server, location
server {
js_challenge_html /etc/nginx/challenge-page.html;
}
The file should contain only <body> content (not <html> or <head> tags). For example:
<div style="text-align: center; padding: 50px; font-family: sans-serif;">
<h1>Verifying your browser</h1>
<p>This security check protects our site from bots.</p>
<p>You will be redirected in a few seconds...</p>
</div>
Important: The file must be readable by the NGINX worker process. Do not place it in /tmp on systems with systemd’s PrivateTmp enabled. NGINX cannot access the real /tmp directory in that case. Use /etc/nginx/ or /usr/share/nginx/html/ instead.
js_challenge_title
Sets the <title> tag text on the challenge page.
Syntax: js_challenge_title <string>;
Default: Verifying your browser...
Context: server, location
server {
js_challenge_title "Security Check - Please Wait";
}
Complete Configuration Example
Here is a production-ready configuration. It protects your site while excluding static assets and health checks:
server {
listen 80;
server_name example.com;
# Enable JavaScript challenge site-wide
js_challenge on;
js_challenge_secret "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
js_challenge_bucket_duration 3600;
js_challenge_title "Checking your browser - please wait";
js_challenge_html /etc/nginx/challenge-body.html;
# Main site - protected by the JavaScript challenge
location / {
proxy_pass http://127.0.0.1:8080;
}
# Static files - no challenge needed
location /static/ {
js_challenge off;
alias /var/www/static/;
expires 30d;
}
# Images and CSS - no challenge needed
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
js_challenge off;
root /var/www/html;
expires 7d;
}
# Health check - must be accessible by monitoring tools
location = /health {
js_challenge off;
return 200 "OK\n";
}
}
Recommended: Protecting Only Sensitive Areas
Because the JavaScript challenge returns HTTP 503, it is best applied to non-public areas that do not need search engine indexing. Admin panels, login forms, and API endpoints are ideal candidates. This avoids any risk of interfering with SEO on your public pages.
server {
listen 80;
server_name example.com;
js_challenge_secret "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
location / {
root /var/www/html;
}
# Protect admin area with tighter challenge rotation
location /admin {
js_challenge on;
js_challenge_bucket_duration 600; # 10-minute validity
proxy_pass http://127.0.0.1:8080;
}
# Protect login endpoint
location = /login {
js_challenge on;
proxy_pass http://127.0.0.1:8080;
}
}
If you do need site-wide protection (for example, during an active DDoS attack), enable the JavaScript challenge globally as a temporary measure. Just be aware that crawlers will be blocked until you disable it or add a bypass for verified bots.
Combining with Rate Limiting
The NGINX JavaScript challenge module pairs well with rate limiting. The JavaScript challenge filters bots that cannot run JavaScript. Rate limiting controls request volume from those that can:
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 80;
server_name example.com;
js_challenge on;
js_challenge_secret "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}
This layered approach means a bot must solve the JavaScript challenge and stay within rate limits. That makes automated abuse significantly harder.
Testing Your Configuration
After enabling the NGINX JavaScript challenge, verify it works correctly.
Verify the Challenge Page
Use curl to confirm that requests without the cookie receive the challenge:
curl -s -o /dev/null -w "%{http_code}" http://localhost/
This should return 503. Inspect the full response:
curl -s -D - http://localhost/ | head -20
Expected output:
HTTP/1.1 503 Service Temporarily Unavailable
Server: nginx/1.28.2
Content-Type: text/html;charset=utf-8
Verify Static Assets Bypass
Confirm that locations with the JavaScript challenge disabled return normally:
curl -s -o /dev/null -w "%{http_code}" http://localhost/static/style.css
This should return 200.
Verify in a Browser
Open your site in a browser. You should see the challenge page briefly (1-3 seconds). Then the page reloads and shows your actual content. Check developer tools (Application > Cookies) to confirm a res cookie was set.
Performance Considerations
The NGINX JavaScript challenge module is designed for minimal overhead:
- Challenge generation uses one SHA-1 computation per request
- Cookie verification requires two SHA-1 computations
- No database or external service is needed
- No additional dependencies — the module is self-contained
The main impact is on visitor experience. The first request incurs a 1-3 second delay while the browser solves the puzzle. Subsequent requests within the same bucket pass through instantly.
The time-bucketed approach means all requests from one IP within a bucket period share the same challenge. This keeps verification efficient under heavy load.
Security Best Practices
Always Change the Default Secret
The default secret is changeme. Anyone who knows this can pre-compute valid cookies. Always set a unique, random secret:
openssl rand -hex 32
Use Different Secrets Per Environment
Run multiple sites on the same server? Use different secrets for each:
server {
server_name production.example.com;
js_challenge_secret "prod-unique-secret-abc123";
}
server {
server_name staging.example.com;
js_challenge_secret "staging-different-secret-xyz789";
}
Shorten Bucket Duration for Sensitive Areas
For login pages or admin panels, reduce the bucket duration. This makes stolen cookies expire faster:
location /admin {
js_challenge on;
js_challenge_bucket_duration 300; # 5 minutes
}
Combine with Other Security Layers
The NGINX JavaScript challenge module is one layer in a defense-in-depth strategy. For comprehensive protection, combine it with:
- NGINX rate limiting — throttle request rates
- Testcookie module — cookie-based bot detection without JavaScript
- Bot verification — verify legitimate search engine crawlers
- NGINX honeypot — trap and ban bots probing known bad URLs
- ModSecurity WAF — application-layer attack filtering
Known Limitations
Visitors with Cookies Disabled
The module relies on cookies to store the solution. Visitors with cookies disabled will be stuck in an infinite loop. In practice, this affects very few users. Most modern websites require cookies for basic functionality.
NGINX Behind a Load Balancer or Reverse Proxy
Behind a load balancer, all requests appear to come from the proxy’s IP. The JavaScript challenge is tied to client IP, so this causes two problems:
- All visitors share one challenge — they all appear to have the same IP
- Challenge invalidation — different backends with different secrets will reject each other’s cookies
Workaround: Use set_real_ip_from and real_ip_header to restore the original client IP:
set_real_ip_from 10.0.0.0/8; # Your load balancer's IP range
real_ip_header X-Real-IP; # Or X-Forwarded-For
server {
js_challenge on;
js_challenge_secret "same-secret-on-all-backends";
}
Use the same secret across all backend servers. That way a cookie from one server validates on another.
Search Engine Crawlers
Search engine bots (Googlebot, Bingbot) typically do not execute JavaScript. Enabling the JavaScript challenge site-wide will block them from indexing your content.
Solution: Use the bot verification module to identify verified crawlers. Then bypass the JavaScript challenge for them.
Comparison with Other Bot Protection Methods
| Method | Blocks non-JS bots | User friction | Overhead | Cookie required |
|---|---|---|---|---|
| JavaScript challenge | Yes | Low (1-3s) | Minimal | Yes |
| Testcookie | Partial | None | Minimal | Yes |
| CAPTCHA | Yes | High | Moderate | Varies |
| Rate limiting | No (limits only) | None | Minimal | No |
| Honeypot | Traps probing bots | None | Minimal | No |
The NGINX JavaScript challenge module occupies a sweet spot. It is more effective than simple cookie checks. It is less intrusive than CAPTCHAs. And it stops a broader category of bots than rate limiting alone.
Troubleshooting
Challenge Page Keeps Looping
If the browser shows the challenge page in an infinite loop:
- Check cookies: Open developer tools and verify a
rescookie is being set. A browser extension may be blocking JavaScript - Check the secret: Ensure the same
js_challenge_secretis used consistently. Changing the secret invalidates existing cookies - Check the time: The challenge is time-bucketed. If the server clock is off, challenges may expire immediately. Use NTP to keep it synchronized
Custom HTML Page Not Loading
If NGINX fails to start after setting js_challenge_html:
nginx: [emerg] js_challenge_html: Could not open file '/path/to/file': Permission denied
Verify the file exists and is readable by the NGINX worker:
ls -la /path/to/challenge.html
chmod 644 /path/to/challenge.html
On systemd systems with PrivateTmp=true, NGINX cannot access /tmp. Move your HTML file to /etc/nginx/ instead.
Module Not Loading
If nginx -t reports unknown directive "js_challenge":
- Verify the module is installed:
rpm -q nginx-module-js-challenge - Verify
load_moduleis at the top ofnginx.conf, beforehttpandeventsblocks - Verify the file exists:
ls /usr/lib64/nginx/modules/ngx_http_js_challenge_module.so
Conclusion
The NGINX JavaScript challenge module provides a lightweight, self-hosted alternative to Cloudflare’s “Checking your browser” page. It filters bots and scripts that cannot execute JavaScript, with virtually no server-side overhead. It works best on sensitive, non-public endpoints like admin panels and login pages, where the HTTP 503 challenge response has no SEO impact.
For best results, combine it with rate limiting and bot verification for a comprehensive defense strategy.
The module is available from the GetPageSpeed repository for RHEL-based distributions. Source code is on GitHub.
