Tor Onion Services
An onion service is a site that Tor Browser reaches at a .onion address, with no exit node and no TLS certificate. Tor encrypts the whole path. Visitors often run Tor Browser at its Safest level, which turns JavaScript off. They accept no third-party requests, external fonts or trackers. A Wisp app already works with JavaScript off (Pages and forms without JavaScript). This page covers what changes on an onion.
Turn it on
WISP_ONION=on HOST=127.0.0.1 WISP_SECRET=... ./my-app
WISP_ONION is on by itself when ORIGIN or SITE_URL is a .onion address, so SITE_URL=http://abcdefgh....onion is enough. WISP_ONION=off turns it off. Wisp reads it once at start. A request to a .onion Host gets the cookie and address rules below whether the switch is on or not, so one process can serve a clearnet site and its onion twin. cx.is_onion() says which one a handler is answering.
| What | On an onion site |
|---|---|
referrer-policy | no-referrer, on pages and error pages, unless the app sets its own. No page tells another site where its visitor came from |
strict-transport-security | never, even with WISP_HSTS=on, because a browser ignores it over plain http. On any site Wisp also leaves it out when the proxy says x-forwarded-proto: http or the host is a .onion |
content-security-policy | img-src 'self' data: (no https: images), object-src 'none', and script-src 'none' for an app with no browser code (no <script>, no .js in src/lib, no custom elements, no inline script, no service worker). Its pages then do not even name wisp.js, which only improves forms and links that work whole without it. wisp::csp(..) still replaces any directive |
date | rounded down to the minute, because the exact second of a server's clock can identify the machine on the clearnet. A response with a max-age under a minute may look stale to a cache |
| Cookies | no Secure (the browser sees plain http), and SameSite=None is Lax. The browser may refuse a Secure cookie on plain http, and then the visitor is signed out at once |
x-forwarded-*, WISP_CLIENT_IP_HEADER | ignored. Tor adds none, so a header that claims a client address is the visitor's own. The startup log says so when you set the variable |
Sitemap, feed, robots.txt, canonical and hreflang | http://abcdefgh....onion/..., built from the .onion Host when SITE_URL is unset. Redirects, href and action in your templates are already relative |
| Static export | each page carries <meta name="referrer"> and a <meta http-equiv="Content-Security-Policy">, and _headers (every export writes it) has the same as headers, for a host that adds none |
Wisp sends no Server, X-Powered-By or Via header. It sets no cookie unless your code does. It loads nothing from another site, because the built-in error page and styles are inline and local. Keep your own fonts on your origin too (src/fonts.txt with a local file, or Open_Sans google ..., which downloads once at build and serves from your origin). Do the same for images and scripts. CSP refuses the rest.
With Tor on the same machine
Tor connects to your app on the loopback address. Bind to it so nothing on the network can bypass Tor:
# /etc/tor/torrc
HiddenServiceDir /var/lib/tor/my-app/
HiddenServicePort 80 127.0.0.1:3000
sudo systemctl reload tor
sudo cat /var/lib/tor/my-app/hostname
Run the app as a service as in Host on a VPS (wisp service install --user my-app --port 3000), with this environment file:
# /etc/my-app.env
HOST=127.0.0.1
WISP_SECRET=<32 or more random characters>
SITE_URL=http://abcdefgh....onion
The hostname file is the address and hs_ed25519_secret_key beside it is the identity. Back them up and never publish the key. If you lose the key, you lose the address.
Two Tor settings (0.4.8 and later) help a busy or attacked site. HiddenServicePoWDefensesEnabled 1 makes a flood of clients pay in computation. HiddenServiceEnableIntroDoSDefense 1 limits introductions. A private site lists its readers' public keys under authorized_clients/.
In Docker
wisp build --docker --onion
WISP_SECRET=... docker compose up -d
docker compose exec tor cat /var/lib/tor/hidden_service/hostname
This writes the usual Dockerfile (with WISP_ONION=on), a docker-compose.yml with the app and a Tor service, tor.Dockerfile and torrc. It publishes no port to the host, so only Tor reaches the app. Keep the tor-keys volume (the address) and the data volume (your saved tables). The command keeps existing files unless you pass --force.
As Static Files
A site with no forms can be plain files behind any web server.
SITE_URL=http://abcdefgh....onion wisp build --static --out dist
Serve dist/ from nginx or Caddy on 127.0.0.1:8080 and point HiddenServicePort 80 127.0.0.1:8080 at it. A static host adds none of the headers that a running app adds. The pages carry the referrer and CSP as <meta> tags, and dist/_headers lists the headers to copy into the server's config.
server {
listen 127.0.0.1:8080;
root /srv/site/dist;
server_tokens off;
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
}
nginx and Caddy date every answer to the second, and their Server header is the first thing a scanner reads. A running Wisp app is the safer choice.
No Client Address
Tor gives your app no client address. Every request comes from Tor on 127.0.0.1, so cx.client_ip() is the same for everyone, and a rate limit keyed by it is one shared bucket that one visitor can empty for all. RATE_LIMIT and cx.limit count by cx.visitor() instead. Behind Tor, this is a signed ticket cookie that each visitor carries, with the whole site's allowance on top because tickets cost nothing to make (Security). For a key of your own, limit by what the request is for:
static LOGINS: RateLimit = RateLimit::per_minute(30);
#[action]
fn login(name: String) -> Result {
LOGINS.check("login")?; // all visitors share it: a ceiling, not a fairness rule
...
}
Or limit by account (LOGINS.check(&name)?), which stops password guessing against one account from anywhere. Tor can also pass a circuit identifier with HiddenServiceExportCircuitID haproxy, which needs the PROXY protocol. Wisp does not read it, because that would add work to every connection. To count each circuit on its own, put HAProxy in front and let it set X-Forwarded-For, as Runtime and build shows.
Forms Without Referer or Origin
Tor Browser strips Referer, and a page with no-referrer makes the browser send Origin: null with its own form posts. Wisp's form check accepts Origin: null only with Sec-Fetch-Site: same-origin, which a page on another site cannot fake. A POST with a foreign Origin, or null with any other Sec-Fetch-Site, is still a 403.
The Clearnet Twin
A site at https://example.com that also has an onion can tell Tor Browser. Tor Browser then offers the visitor the onion address of the current page:
WISP_ONION_LOCATION=abcdefgh....onion ./my-app
Every successful GET or HEAD answer then carries onion-location: http://abcdefgh....onion/the/same/path?query. Tor Browser acts on it over https only. For one route, cx.onion_location("abcdefgh....onion") in before or a page does the same. A request that came over the onion gets none. A value that is not an onion address stops the server at start (WISP_ONION_LOCATION). For cx.onion_location, Wisp drops the value and logs it. The twin's sitemap and canonical links stay on the onion when its own Host is the one named here, and never take a Host an attacker picks.
More
Pages and forms without JavaScript, Environment variables, Serve extras, Host on a VPS, Docker.
Is This Page Useful?