Skip to content
WebShield Docs

Origin access through WebShield only

Close your server to direct requests using the WebShield secret header — examples for nginx, Apache, IIS, Caddy and more, plus automatic bans with fail2ban.

Your domain now hides behind WebShield, but the server keeps its old IP address. Anyone who knows it — an old DNS record, a leaked email header, a subnet scan — talks to your site directly and walks straight past the protection.

The cure: your server stops answering anyone but us.

Every request we send to your server carries a header with the secret of that host:

X-WebShield-Origin-Token: wso_…

Your server checks the header and answers only requests that carry it. Everything else is refused. A visitor’s own header of the same name never reaches you — we overwrite it with our value.

Why not a list of our addresses: the list moves. Nodes come up under load and go out of service, and your firewall would have to follow our network around. A secret does not depend on topology — set it once and forget it.

The token lives in the control panel: Hosts → the host → Access through WebShield only. Each host has its own value.

  1. All traffic already goes through us. The site’s DNS record is proxied and no direct A record points at the server. Otherwise you will shut the site off from your own visitors.
  2. You have a way back in. SSH, a hosting panel — anything that lets you undo the change without opening the site.

Certificates need no special care: the Let’s Encrypt challenge arrives at your domain, which means at us, and we pass it to your server by the same route and with the same header. No exception for /.well-known/acme-challenge/ is needed — and making one leaves ajar exactly the door you came to close.

A direct request should be refused, a request through us should work:

Terminal window
# Straight to the server address — expect 403.
curl -sk -o /dev/null -w '%{http_code}\n' https://203.0.113.10/ -H 'Host: example.com'
# Through WebShield — expect a normal response.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/

The status code is yours to pick: 403 is more honest than 401 (no authentication is involved here), and nginx’s 444 drops the connection without a word. It makes no difference to the site — visitors never see these answers, only the people who came around us do.

server {
server_name example.com;
# Not from WebShield — no further.
if ($http_x_webshield_origin_token != "wso_YOUR_TOKEN") { return 403; }
# ... your usual configuration: proxy_pass, root, fastcgi_pass ...
}

Test and apply: nginx -t && systemctl reload nginx.

Apache 2.4, in the site configuration or in .htaccess — no extra modules needed:

<If "%{HTTP:X-WebShield-Origin-Token} != 'wso_YOUR_TOKEN'">
Require all denied
</If>

Test and apply: apachectl configtest && systemctl reload apache2.

The same syntax works on LiteSpeed and OpenLiteSpeed with .htaccess compatibility enabled.

example.com {
@notWebShield not header X-WebShield-Origin-Token "wso_YOUR_TOKEN"
respond @notWebShield 403
reverse_proxy localhost:3000
}

Apply: caddy reload --config /etc/caddy/Caddyfile.

Requires the URL Rewrite module. In the site’s web.config:

<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="WebShield only" stopProcessing="true">
<match url=".*" />
<conditions>
<add input="{HTTP_X_WEBSHIELD_ORIGIN_TOKEN}"
pattern="^wso_YOUR_TOKEN$" negate="true" />
</conditions>
<action type="CustomResponse" statusCode="403" statusReason="Forbidden"
statusDescription="Forbidden" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

The token goes straight into the router rule:

http:
routers:
site:
rule: "Host(`example.com`) && Header(`X-WebShield-Origin-Token`, `wso_YOUR_TOKEN`)"
service: site

A request without the token does not match the rule, so there is no route for it — Traefik answers 404.

frontend https-in
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
acl from_webshield req.hdr(X-WebShield-Origin-Token) -m str wso_YOUR_TOKEN
http-request deny deny_status 403 unless from_webshield
default_backend site
const TOKEN = process.env.WEBSHIELD_ORIGIN_TOKEN;
app.use((req, res, next) => {
if (req.get("x-webshield-origin-token") === TOKEN) return next();
res.status(403).end();
});

Keep the token in an environment variable, not in the repository.

At the very top of the entry point (index.php), before the framework loads:

$token = getenv('WEBSHIELD_ORIGIN_TOKEN');
$sent = $_SERVER['HTTP_X_WEBSHIELD_ORIGIN_TOKEN'] ?? '';
if (!hash_equals($token, $sent)) {
http_response_code(403);
exit;
}

For WordPress and other off-the-shelf systems, put the check in the web server rather than in code — a CMS update will not wipe it.

As a WSGI/ASGI layer, ahead of the application’s routing:

import os
from hmac import compare_digest
TOKEN = os.environ["WEBSHIELD_ORIGIN_TOKEN"]
class WebShieldOnly:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
sent = environ.get("HTTP_X_WEBSHIELD_ORIGIN_TOKEN", "")
if not compare_digest(sent, TOKEN):
start_response("403 Forbidden", [("Content-Type", "text/plain")])
return [b"Forbidden"]
return self.app(environ, start_response)

A refusal is not an error, it is evidence: no live visitor can reach your server directly — they all come through us. So an address in that log belongs to someone hunting for your origin. You can ban it without further thought, and ban it for a long time.

Write refusals to a file of their own instead of fishing them out of the general log.

nginx. The format is declared next to the others (the http context), and the check moves inside the location:

log_format ws_denied '$remote_addr $host "$request" $status';
server {
server_name example.com;
location / {
if ($http_x_webshield_origin_token != "wso_YOUR_TOKEN") {
access_log /var/log/nginx/origin-denied.log ws_denied;
return 403;
}
# ... your usual configuration ...
}
}

Apache. Conditional logging on the same check:

SetEnvIfExpr "%{HTTP:X-WebShield-Origin-Token} != 'wso_YOUR_TOKEN'" ws_denied
CustomLog /var/log/apache2/origin-denied.log common env=ws_denied
<If "%{HTTP:X-WebShield-Origin-Token} != 'wso_YOUR_TOKEN'">
Require all denied
</If>

The filter goes into /etc/fail2ban/filter.d/webshield-origin.conf. Only refusals land in that file, so matching the address at the start of the line is enough:

[Definition]
failregex = ^<HOST>
ignoreregex =

The jail goes into /etc/fail2ban/jail.d/webshield-origin.conf:

[webshield-origin]
enabled = true
filter = webshield-origin
logpath = /var/log/nginx/origin-denied.log
maxretry = 1
findtime = 1d
bantime = 30d
banaction = nftables-allports

maxretry = 1 is not a typo: there is no reason to give such an address a second chance. Pick the banaction your firewall uses — nftables-allports, iptables-multiport or ufw.

Check that the jail loaded: fail2ban-client status webshield-origin.

Refusals land in the general access.log, where they look exactly like your application’s own 403s — a ^<HOST> filter would ban everyone, live visitors included. The line needs a mark you can recognise.

nginx — answer 444. The connection is dropped without a word, and the log gets a status no application ever returns:

if ($http_x_webshield_origin_token != "wso_YOUR_TOKEN") { return 444; }
[Definition]
failregex = ^<HOST> -.*" 444 \d+
ignoreregex =
[webshield-origin]
enabled = true
filter = webshield-origin
logpath = /var/log/nginx/access.log
maxretry = 1
findtime = 1d
bantime = 30d
banaction = nftables-allports

nginx — keep 403 and add a mark to the log format. Useful when 444 is already taken by other rules:

log_format ws_combined '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" $ws_denied';
server {
server_name example.com;
access_log /var/log/nginx/access.log ws_combined;
# The declaration is required, otherwise nginx complains about an unknown variable.
set $ws_denied "-";
if ($http_x_webshield_origin_token != "wso_YOUR_TOKEN") {
set $ws_denied "ws-denied";
return 403;
}
# ... your usual configuration ...
}

Apache — the same mark through an environment variable:

SetEnvIfExpr "%{HTTP:X-WebShield-Origin-Token} != 'wso_YOUR_TOKEN'" ws_denied=ws-denied
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %{ws_denied}e" ws_combined
CustomLog ${APACHE_LOG_DIR}/access.log ws_combined

Either way a refusal line ends with the word ws-denied, while every other line has a dash there. The filter catches exactly that:

[Definition]
failregex = ^<HOST> .* ws-denied$
ignoreregex =

The jail stays the same; only logpath points at the general log.

The Reissue button on the host card hands out a new value; the old one stops working within a minute.

The order here is the reverse of switching on: the new value reaches us first (the button), your server second. In between, the site answers with an error, so have the configuration ready. On servers where the change cannot be applied instantly, accept both values for a while — the old one and the new one.

Reissue if the value ends up in a shared repository, a screenshot or a chat.

  • 403 for every visitor. The token on the server does not match the one in the panel — compare the values in full, including the wso_ prefix.
  • Some paths fail. The rule did not cover the whole site: nested location blocks, <Directory> sections and routes can carry checks of their own.
  • Assets are served but pages are not (or the other way round). Some panels serve static files from a separate server — that one needs the rule too.

The token stops requests that bypass us; it is not a firewall. Database ports, panels and SSH still need closing on your side.