Skip to main content

NGINX / Security

NGINX JavaScript Challenge: Stop Bots Without CAPTCHAs

by , , revisited on


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth 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:

  1. Initial request — The visitor hits your server without a verification cookie
  2. Challenge page — NGINX returns an HTTP 503 response with an embedded JavaScript challenge
  3. Proof-of-work — The browser computes a SHA-1 hash that satisfies specific conditions
  4. Cookie set — Once solved, JavaScript sets a res cookie containing the solution
  5. Page reload — The browser automatically reloads after 3 seconds
  6. Verification — NGINX validates the cookie against the expected challenge
  7. 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";
    }
}

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:

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:

  1. All visitors share one challenge — they all appear to have the same IP
  2. 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:

  1. Check cookies: Open developer tools and verify a res cookie is being set. A browser extension may be blocking JavaScript
  2. Check the secret: Ensure the same js_challenge_secret is used consistently. Changing the secret invalidates existing cookies
  3. 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":

  1. Verify the module is installed: rpm -q nginx-module-js-challenge
  2. Verify load_module is at the top of nginx.conf, before http and events blocks
  3. 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.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.