quickstart
add bot protection to any website in under two minutes. you need a brickwall account and one line of html.
1. create an account
go to brickwall.onrender.com and register. you'll be taken straight to the dashboard.
2. add your site
in the dashboard, click add site. enter a name and your domain (e.g. yoursite.neocities.org). brickwall will generate a unique site key.
3. add the script tag
paste this into the <head> of every page you want protected:
<script src="https://brickwall.onrender.com/js/protect.min.js" data-site="YOUR_SITE_KEY"></script>
replace YOUR_SITE_KEY with the key shown in your dashboard under installation.
4. test it
open your site in a private/incognito window. you should be redirected to the brickwall challenge page, solve it in under 2 seconds, then land back on your site. subsequent visits will be instant — the token is cached in localStorage.
how it works
brickwall is a lightweight bot-gate. it sits between your visitor and your site's content without requiring any server-side code on your end.
the flow
1. visitor arrives → protect.min.js runs and checks localStorage for a valid signed token.
2. no token → visitor is redirected to brickwall.onrender.com/challenge.html with your site key and their return URL in the query string.
3. challenge → the challenge page runs browser fingerprinting checks and a proof-of-work puzzle (SHA-256 hash with leading zeros). takes ~1–2 seconds for a real browser.
4. verification → the solution is sent to the brickwall server, which checks the hash, validates timing (rejects solutions <200ms — too fast for a human), and issues a signed JWT.
5. return → visitor is redirected back to your site with the token appended as ?bw_token=.... the script picks it up, saves it to localStorage, and strips it from the URL.
6. subsequent visits → the token is read from localStorage, the expiry is checked client-side, and the visitor passes through silently. no network request needed.
the token
tokens are JWTs signed with a secret only the brickwall server knows. they contain the site ID and an expiry timestamp. the client-side script decodes the expiry from the JWT payload without a server call to check freshness — it only makes a network request when there's no token at all.
installation
script tag (recommended)
place this in <head> before other scripts. it must load synchronously to prevent unprotected content from flashing.
<head> <!-- brickwall — place before other scripts --> <script src="https://brickwall.onrender.com/js/protect.min.js" data-site="bw_live_xxxxxxxxxxxx"></script> </head>
attributes
| attribute | description |
|---|---|
| data-siterequired | your site key from the dashboard. starts with bw_live_. |
| data-apioptional | override the api base url. defaults to https://brickwall.onrender.com. useful if self-hosting. |
| data-challengeoptional | override the challenge page base url. defaults to https://brickwall.onrender.com. |
what the script does
on every page load, protect.min.js:
1. checks the URL for a bw_token param (set after a successful challenge) — saves it to localStorage and strips it from the URL using history.replaceState.
2. reads the token from localStorage and decodes the JWT expiry field client-side.
3. if the token is missing or expired, immediately redirects to the challenge page.
4. if the token is valid, does nothing — the user sees the page normally.
data-site attribute.site settings
each site has independent settings accessible from the dashboard under site settings.
| setting | default | description |
|---|---|---|
| allowCrawlers | true | automatically pass googlebot, bingbot, and other known crawlers. they're logged as "crawler" with status "passed". |
| blockTor | false | block known tor exit node ip ranges before the challenge. blocked visitors see an access denied page. |
| blockVpn | false | flag or block known datacenter asns and vpn exit ip ranges. |
| challengeTtl | 24 | how many hours a verified token lasts. after this, visitors are re-challenged silently. |
| active | true | when false, all visitors pass through without any challenge. useful for maintenance or testing. |
token ttl
the token TTL controls how long a verified visitor goes without being re-challenged. the default is 24 hours. you can set it anywhere from 1 to 720 hours (30 days).
for high-security sites, use a shorter TTL (1–6 hours). for low-friction user experience, use 72–168 hours. the re-challenge is transparent to the user — they'll see the brickwall page for ~1 second and be returned.
authentication api
sessions use http-only cookies (bw_session). no bearer tokens needed for dashboard api calls.
POST/api/auth/register
| field | type | description |
|---|---|---|
| namerequired | string | display name |
| emailrequired | string | must be unique |
| passwordrequired | string | minimum 8 characters |
returns {"ok": true, "name": "..."} and sets bw_session cookie.
POST/api/auth/login
| field | type | description |
|---|---|---|
| emailrequired | string | |
| passwordrequired | string |
POST/api/auth/logout
clears the session cookie.
GET/api/auth/me
returns {"id", "email", "name"} for the current session. 401 if not logged in.
DELETE/api/auth/account
permanently deletes the account and all associated sites, keys, and request history. clears session cookie.
sites api
all endpoints require an active session cookie.
GET/api/sites
returns array of sites belonging to the current user.
POST/api/sites
| field | description |
|---|---|
| namerequired | display name for the site |
| domainrequired | domain only, e.g. example.com. protocol and path are stripped automatically. |
PUT/api/sites/:id
update name, settings, or active state. only provided fields are updated.
{
"name": "my site",
"active": false,
"settings": {
"allowCrawlers": true,
"blockTor": false,
"blockVpn": false,
"challengeTtl": 24
}
}
DELETE/api/sites/:id
permanently deletes the site, its api key, and all request history.
POST/api/sites/:id/rotate
generates a new api key for the site. all existing visitor tokens are immediately invalidated — visitors will be re-challenged on their next visit. returns {"key": "bw_live_..."}.
GET/api/sites/:id/requests
returns the last 500 verification attempts for the site, newest first.
GET/api/sites/:id/stats
returns {"total", "passed", "blocked", "flagged"} counts for all recorded requests.
challenge api
these endpoints are called by the challenge page and the embed script. you don't need to call them directly unless you're building something custom.
POST/api/challenge/init
initialize a challenge for a site key. returns a challenge ID and proof-of-work difficulty, or skips straight to a token for known crawlers.
| field | description |
|---|---|
| siteKeyrequired | the bw_live_... key from the embed script |
| returnUrloptional | url to redirect to after challenge passes |
POST/api/challenge/verify
submit a proof-of-work solution. returns a signed JWT on success.
| field | description |
|---|---|
| challengeIdrequired | id from /init |
| noncerequired | the nonce that produces a hash with the required leading zeros |
| elapsedrequired | milliseconds taken to solve. must be ≥ 200ms or the solve is rejected as automated. |
POST/api/challenge/check
verify a token server-side. returns {"valid": true/false}. useful for backend verification — see server-side verification.
| field | description |
|---|---|
| tokenrequired | the JWT from localStorage / the bw_token url param |
| siteKeyrequired | your site key |
neocities / static sites
brickwall was built with static hosts in mind. there's no server to configure — just paste the script tag.
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <!-- add this line --> <script src="https://brickwall.onrender.com/js/protect.min.js" data-site="bw_live_your_key_here"></script> <title>my site</title> </head> <body> ... </body> </html>
paste this into every .html file you want protected. on neocities, you can edit files directly in the browser or upload them via the dashboard.
single-page apps
for SPAs (react, vue, svelte, etc.), add the script tag to your index.html. since brickwall redirects at the browser level before js frameworks initialize, it works regardless of your routing setup.
<head> <meta charset="UTF-8"> <!-- brickwall must load before your app bundle --> <script src="https://brickwall.onrender.com/js/protect.min.js" data-site="bw_live_your_key_here"></script> <link rel="stylesheet" href="/dist/bundle.css"> </head> <body> <div id="root"></div> <script src="/dist/bundle.js"></script> </body>
the redirect happens synchronously before your app mounts — if there's no valid token, the browser never renders your app content.
server-side verification
if you have an api or backend that should only respond to verified visitors, you can verify tokens server-side using the /api/challenge/check endpoint.
async function requireBrickwall(req, res, next) { const token = req.headers['x-brickwall-token'] if (!token) return res.status(401).json({ error: 'not verified' }) const r = await fetch('https://brickwall.onrender.com/api/challenge/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, siteKey: process.env.BRICKWALL_SITE_KEY }) }) const { valid } = await r.json() if (!valid) return res.status(403).json({ error: 'invalid token' }) next() }
on the client side, pass the token in a request header:
const token = localStorage.getItem('bw_token_YOUR_SITE_KEY') fetch('/api/protected-route', { headers: { 'x-brickwall-token': token } })
multiple sites
each site in your dashboard gets its own unique key. tokens are scoped to a site key — a visitor verified on site A cannot use that token on site B.
to protect multiple sites, add each domain in the dashboard and use the corresponding key in each site's script tag. they'll all show up as separate entries in your dashboard with their own request logs and settings.
edge cases
| scenario | behavior |
|---|---|
| googlebot / bingbot | auto-bypassed if "allow crawlers" is on. logged as crawler/passed. token issued immediately. |
| tor exit nodes | blocked before challenge if "block tor" is on. blocked visitors see an access denied screen. |
| vpn / datacenter ips | flagged in the request log. can be configured to block outright. |
| headless chrome / puppeteer | blocked by navigator.webdriver === true check on the challenge page. |
| phantomjs / nightmare | blocked by artifact detection on the challenge page. |
| javascript disabled | noscript fallback message shown. content is not accessible without js. |
| pow solved in <200ms | rejected as automated. a human browser cannot solve it that fast. |
| challenge expires (2min) | user is redirected back to the challenge page cleanly on retry. |
| token expired | cleared from localStorage, visitor re-challenged transparently. |
| rate limited (15 req/min) | ip is rate limited for 60 seconds. user sees a countdown message. |
| site set to inactive | all visitors pass through without a challenge. |
troubleshooting
"unknown site key"
the key in your embed script doesn't match any site in the database. this usually happens after a server restart when data was previously stored in memory (pre-database). go to your dashboard → installation, copy the current key, and update the data-site attribute in your html.
"automated browser detected"
the challenge page's headless browser detection triggered. this should only happen for actual automation tools. if you're seeing this on a real browser, open a github issue — it may be a false positive we haven't seen before.
infinite redirect loop
the most common cause is the token being saved to the wrong origin. make sure you're using the latest version of protect.min.js — older versions saved the token to brickwall's localStorage instead of your site's. re-deploy with the latest script tag.
requests not showing in dashboard
requests are only logged during /api/challenge/init and /api/challenge/verify. if the redirect from your site to the challenge page never happens, no requests are logged. check the browser console for errors from protect.min.js.
can't scroll in dashboard
clear your browser cache and hard-reload (ctrl+shift+r / cmd+shift+r). if you deployed before the scroll fix, you may be serving an old cached version.
login says "invalid credentials"
if you registered before the postgres migration, your account was stored in memory and lost on restart. register a new account — this time it'll persist in the database.