Skip to main content

NGINX

NGINX JSONP: Safe Cross-Origin APIs with the XSS Module

by ,


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.

You have a JSON endpoint on NGINX, and a browser on another origin needs to read it. CORS headers are the modern answer, but CORS does not always fit: the client may sit on a static CDN you do not own, a corporate proxy may strip your Access-Control-Allow-Origin header, or a third-party widget may embed your data on pages you cannot touch. In those cases, NGINX JSONP is still the only thing that works.

The problem is that most NGINX JSONP implementations built by hand are one sloppy regex away from reflected XSS. The server takes a callback query parameter, wraps the JSON body in callback(...), and ships it with an application/javascript MIME type. If the callback sanitizer misses a character, the attacker gets to inject arbitrary JavaScript into anything that loads the script tag. This is not theoretical – Rosetta Flash, content-type confusion, and stored XSS via reflected callback names are all well-documented JSONP attack classes.

The NGINX XSS module solves both sides of this problem at once: it produces JSONP responses automatically, and it validates the callback parameter against a strict JavaScript-identifier grammar before a single byte reaches the wire. If the callback is not a well-formed identifier, dotted namespace, or single numeric index, the wrapper is never emitted and the attacker gets nothing. In this guide, you will install the module, configure a hardened NGINX JSONP endpoint, and verify the callback validator against real injection payloads.

How the XSS Module Works

ngx_http_xss_module is a combined header and body filter. It watches every outbound response on locations where xss_get is enabled and applies a simple decision tree. The module only wraps a response when all of the following conditions are true:

  • The request method is GET (POSTs are skipped entirely)
  • The response status is 200 or 201, unless xss_check_status is turned off
  • The Content-Type of the upstream response matches one of the types listed in xss_input_types (default: application/json)
  • The request query string contains the argument named by xss_callback_arg
  • The callback value, after URL decoding, passes the strict JavaScript-identifier validator

When the wrapper is emitted, the body filter prepends callbackName( to the first chunk and appends ); after the last chunk. The Content-Type header is rewritten to the value of xss_output_type (default application/x-javascript), the Content-Length header is cleared, and the response is streamed back through the standard NGINX filter chain.

The Callback Validator

The callback validation is where the module earns its keep. Under the hood it is a Ragel state machine that implements this grammar:

identifier = [$A-Za-z_] [$A-Za-z0-9_]*
index      = [0-9]* '.' [0-9]+ | [0-9]+
callback   = identifier ('.' identifier)* ('[' index ']')?

In plain English: a callback name must start with a dollar sign, underscore, or letter, followed by any number of identifier characters. Dotted namespace paths like App.handlers.onUsers are allowed. A single bracketed numeric index like window.handlers[0] is allowed at the end. Everything else – parentheses, quotes, angle brackets, spaces, backticks, commas – is rejected outright, and the module falls through to serve the original unwrapped response. Rejected callbacks are logged to the NGINX error log at the error level with the literal value, which is useful for feeding into your WAF analytics later.

Installation

RHEL, CentOS, AlmaLinux, Rocky Linux

Install the XSS module from the GetPageSpeed repository:

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-xss

Then load the module in your NGINX configuration:

load_module modules/ngx_http_xss_filter_module.so;

Debian and Ubuntu

First, set up the GetPageSpeed APT repository, then install:

sudo apt-get update
sudo apt-get install nginx-module-xss

On Debian/Ubuntu, the package handles module loading automatically. No load_module directive is needed.

View the full module documentation at:

Configuration Directives

The module exposes six directives. All of them are valid in http, server, location, and if in location contexts, so you can scope the behavior as tightly as needed.

xss_get

Syntax: xss_get on | off;
Default: xss_get off;
Context: http, server, location, if in location

Enables JSONP wrapping for GET responses in the current context. This is the master switch for NGINX JSONP on any given location: if it is off, nothing else in the module runs.

xss_callback_arg

Syntax: xss_callback_arg name;
Default: (none)
Context: http, server, location, if in location

Specifies the query parameter that holds the JavaScript callback name. If xss_get is on but xss_callback_arg is unset, the module logs xss_get is enabled but no xss_callback_arg specified to the error log and refuses to wrap anything. There is no default because an empty name would silently disable the feature, which is the opposite of what you want.

xss_callback_arg callback;

xss_input_types

Syntax: xss_input_types mime-type ...;
Default: xss_input_types application/json;
Context: http, server, location, if in location

Lists the response MIME types that the module will wrap. Responses whose Content-Type does not match one of the listed types pass through unchanged. This is a security feature: you do not want an HTML error page or a binary download accidentally wrapped in callback(...);.

xss_input_types application/json text/plain;

Note that listing application/json here explicitly on top of the default triggers a duplicate-MIME-type warning at startup. Either rely on the default, or override it entirely.

xss_output_type

Syntax: xss_output_type mime-type;
Default: xss_output_type application/x-javascript;
Context: http, server, location, if in location

Sets the Content-Type header on wrapped responses. The default (application/x-javascript) is historically what <script> tags expect; modern browsers also accept application/javascript and text/javascript. Pick whichever your clients treat consistently.

xss_check_status

Syntax: xss_check_status on | off;
Default: xss_check_status on;
Context: http, server, location, if in location

When on (the default), the module only wraps responses with HTTP status 200 or 201. Any other status passes through untouched, which prevents internal error pages, redirects, and upstream 5xx responses from ever being delivered as JSONP. Turn this off only if you intentionally want to ship error bodies as JSONP and you have already formatted them as JSON.

xss_override_status

Syntax: xss_override_status on | off;
Default: xss_override_status on;
Context: http, server, location, if in location

When the module does wrap a response whose status is in the 3xx/4xx/5xx range (only possible with xss_check_status off), this directive rewrites the status to 200 OK before headers are sent. Legacy JSONP clients often cannot read the response body from a non-200 status because the browser never invokes the <script> handler, so rewriting to 200 is usually what you want. If you need to preserve real HTTP status codes for observability, set this to off.

Note that the module does not export any NGINX variables. If you were hoping to branch on a $jsonp_wrapped flag in your access log, you will need to infer it from the query string with a map block instead.

A Hardened NGINX JSONP Endpoint

Here is a realistic setup: a public read-only API that returns JSON, serves it as JSONP when a callback is requested, validates the callback strictly, and logs rejected attempts. Drop this in a file such as /etc/nginx/conf.d/api.conf:

# Rate-limit the JSONP surface independently of the rest of the site
limit_req_zone $binary_remote_addr zone=jsonp:10m rate=30r/s;

server {
    listen 8080;
    server_name _;

    # Shield against MIME sniffing on the wrapped response
    add_header X-Content-Type-Options nosniff always;

    location /api/ {
        root /var/www;
        default_type application/json;

        limit_req zone=jsonp burst=60 nodelay;

        # Enable hardened JSONP wrapping
        xss_get on;
        xss_callback_arg callback;

        # Keep the defaults explicit so they survive a future refactor
        xss_check_status on;
        xss_output_type application/javascript;
    }
}

Drop a small JSON file at /var/www/api/users.json:

{"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}

Reload NGINX and verify the configuration:

sudo nginx -t
sudo systemctl reload nginx

Testing the Happy Path

A request without a callback behaves exactly like a static JSON file, so NGINX JSONP wrapping is only applied when the caller opts in:

curl -s http://localhost:8080/api/users.json
{"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}

Adding a callback parameter flips the response into JSONP:

curl -s "http://localhost:8080/api/users.json?callback=handleUsers"
handleUsers({"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}
);

Note the Content-Type change: the wrapped response arrives as application/javascript so browsers will execute it when loaded via <script src="...">.

Namespaced callbacks work too. This is important for real-world clients like jQuery, which generate function names like jQuery112.callbacks_0:

curl -s "http://localhost:8080/api/users.json?callback=App.handlers.onUsers"
App.handlers.onUsers({"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}
);

Testing the Injection Defense

Now try the attacks. A classic reflected-XSS payload:

curl -s "http://localhost:8080/api/users.json?callback=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
{"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}

The module rejected the callback because angle brackets are not valid JavaScript identifier characters. Instead of shipping a wrapped response, it falls through to the raw JSON body with the original application/json content type. Importantly, a browser loading this with a <script> tag would still refuse to execute the body because the MIME type stayed JSON. The error log records exactly what was attempted:

[error] xss: bad callback argument: "<script>alert(1)</script>"

A more subtle attempt with parentheses is similarly blocked:

curl -s "http://localhost:8080/api/users.json?callback=alert(1)"
{"users":[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]}

And again, the rejection is logged:

[error] xss: bad callback argument: "alert(1)"

Because the callback validator is a state machine rather than a regex patchwork, there are no parser quirks to exploit. The full input must match the grammar from start to end; a single stray byte causes rejection.

When to Use NGINX JSONP Instead of CORS

JSONP is legacy technology. For greenfield APIs you almost always want CORS headers via add_header Access-Control-Allow-Origin. CORS gives you proper HTTP semantics, real status codes, POST support, cookies, custom headers, and preflight negotiation. JSONP gives you none of that – it is GET-only, 200-or-nothing, and the response body is executed in the calling page’s JavaScript context.

That said, there are still situations where NGINX JSONP is the only path that works:

  • Static asset hosts. You serve JSON from a CDN bucket that does not let you set response headers per object. CORS requires headers; JSONP does not.
  • Hostile corporate proxies. Some middleboxes strip Access-Control-* headers, breaking CORS for end users on enterprise networks. A <script> tag bypasses this entirely.
  • Third-party widget embeds. A consumer embeds your script on pages you do not control; you cannot rely on the embedding site to send credentials, handle preflights, or negotiate origins.
  • Legacy intranets. IE8 and similar browsers never reliably implemented CORS. If you still support them, JSONP is your interop layer.

If you control the client and the server, use CORS. If you are bolting a read-only API onto infrastructure you do not fully own, the XSS module gives you a safer JSONP than anything you would build by hand.

Security Hardening Checklist for NGINX JSONP

The defaults are already reasonable, but there are a few steps worth taking on top:

  • Set xss_callback_arg explicitly. Never rely on an implicit default. Pick one parameter name for your entire surface area and document it.
  • Keep xss_check_status on. Turning it off lets internal error pages leak through your JSONP channel, potentially revealing stack traces or upstream server details.
  • Leave xss_override_status on for public APIs. Clients that load your responses via <script> tags cannot read bodies from non-200 responses anyway. If you need real status codes for a structured API client, use CORS instead.
  • Scope xss_input_types to the narrowest set you need. The default of application/json is usually correct. Only add other types when you have a concrete reason.
  • Add X-Content-Type-Options: nosniff. This blocks MIME sniffing on the wrapped response and is a general best practice for any application/javascript endpoint. The NGINX security headers module can set this globally.
  • Rate-limit the JSONP location. JSONP endpoints are cheap for an attacker to fetch but can be expensive to generate. A dedicated limit_req zone scoped to the JSONP location limits blast radius.
  • Isolate JSONP locations from authenticated paths. JSONP responses are cross-origin readable by design. Never enable xss_get on a location that serves session-specific data.

Troubleshooting

The module does not wrap anything

Check each gate in turn. The module is extremely quiet when it bails out, but raising the error_log level to info surfaces every decision:

error_log /var/log/nginx/error.log info;

Common culprits:

  • The request is not a GET. POST, HEAD, and OPTIONS are skipped by design.
  • The response Content-Type is not in xss_input_types. Confirm with curl -I that the upstream is actually returning application/json, not text/html or application/octet-stream.
  • The upstream response status is 301, 302, or 5xx. With xss_check_status on, these never get wrapped.
  • The callback argument is missing or empty. The module treats an empty callback as “no callback” and falls through.

The callback is silently rejected

Any rejected callback is logged at the error level with the literal value. Grep your error log:

sudo grep "bad callback argument" /var/log/nginx/error.log

If the value looks valid but is still rejected, double-check for trailing whitespace or non-ASCII characters – the validator accepts only the strict ASCII identifier grammar described earlier. Unicode identifiers are not supported.

Subrequests are not wrapped

The module explicitly skips subrequests at the top of its header filter. This is a deliberate limitation: NGINX’s subrequest chaining is incompatible with the module’s body-filter strategy. If your content handler issues subrequests (via ngx_echo, ngx_addition, or similar), the response will not be wrapped. The official recommendation from the module author is to use ngx_lua‘s ngx.location.capture() as the content handler instead, because its capture mechanism bypasses the postponed-chain limitation.

The first byte of the wrapped response is chunked unexpectedly

The module clears the Content-Length header when it wraps a response, so the body is streamed back as Transfer-Encoding: chunked. This is normal and expected behavior. Downstream proxies and CDNs will re-buffer the response as needed.

Conclusion

The NGINX XSS module gives you a single-directive path to safe NGINX JSONP responses. The strict Ragel-based callback validator closes off the entire class of reflected-code attacks that plague homegrown JSONP implementations, and the MIME-type and status-code gates keep error pages and non-JSON bodies from being exposed through your JSONP channel by accident. If you still need NGINX JSONP in 2026 – and plenty of real deployments do – this is the right way to ship it.

For source code and issue tracking, visit the xss-nginx-module GitHub repository.

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.