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. logged as "crawler / 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 using geoip org lookup. |
| challengeTtl | 24 | how many hours a verified token lasts. after this, visitors are re-challenged. |
| active | true | when false, all visitors pass through without any challenge. |
| allowedBots | {} | per-site bot allowlist. see bot allowlist. |
| challengeUi | {} | per-site challenge page appearance. see challenge page. |
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.
bot allowlist
the bot allowlist lets you pass through specific bots that aren't in the built-in crawler list. common uses: uptime monitors, link preview bots (discord, slack, twitter), custom scrapers you own, analytics crawlers.
allowlisted bots skip the challenge entirely and are logged as detected: allowed / status: passed. the check runs before all other detection, including headless browser detection — so it takes full priority.
robots.txt name matching
enter a bot name exactly as you'd write it in a robots.txt file, e.g. Twitterbot, UptimeRobot, GPTBot. the match is case-insensitive and checks if the name appears anywhere in the user agent string.
the dashboard includes a quick-add grid of 25 common bots so you can add them in one click.
ua string matching
match any substring against the raw user agent string. useful for internal tools or custom bots with non-standard names.
settings shape
{
"names": ["Twitterbot", "UptimeRobot", "GPTBot"],
"uaStrings": ["MyInternalTool/2.0"]
}
domain migration
if your site moves to a new domain, you can update it in place without losing your request history, site settings, or bot allowlist.
how to change your domain
1. in site settings → site info, edit the domain field and click save.
2. a terminal confirmation modal appears. type the command exactly:
$ sudo apt install brickwall-sites new-domain your-new-domain.com
3. on confirm, the domain is updated and the site key is automatically rotated. the dashboard redirects you to the installation panel with the new key and fresh script tag ready to copy.
api
domain migration is also available via the API — see POST /api/sites/:id/domain.
challenge page customization
each site can have its own challenge page appearance. settings live in site settings → challenge page and are applied per-visitor when the challenge loads.
color overrides
8 css variables can be overridden per site. empty values inherit the challenge page defaults.
| variable | description |
|---|---|
| bg | page background color |
| accent | spinner, progress bar, and highlights |
| surface | log panel background |
| text | headline text color |
| text2 | secondary label color |
| muted | subline and log entry text |
| border | panel borders |
| border2 | ring and input borders |
text overrides
headline (max 120 chars), subline (max 300 chars), and logo text (max 60 chars) can each be replaced with custom copy. leave blank to use the defaults.
custom css
inject up to 8KB of arbitrary css into the challenge page. applied after all other styles, so it can override anything. useful for custom fonts, layout changes, or branding beyond what the color pickers cover.
visibility toggles
hide steps log — hides the live proof-of-work step log panel. useful for minimal, branded challenge pages.
hide badge — hides the "protected by brickwall" badge at the bottom.
settings shape
{
"colors": {
"bg": "#0d0d0d",
"accent": "#ff5500"
},
"headline": "just a sec...",
"subline": "checking your browser",
"logoText": "mysite",
"css": "body { font-family: Georgia, serif; }",
"hideStepsLog": true,
"hideBadge": false
}
dashboard customization
the dashboard appearance is stored locally in your browser (localStorage key bw_customize) and applied before the auth check — no flash of default styles.
theme presets
six presets are available: default, slate, chalk, forest, noir, and synthwave. selecting a preset sets all 13 color variables at once. presets are detected automatically — if your current colors match a preset exactly, it highlights in the grid.
color variables
13 css variables can be overridden individually via color pickers with hex input fields. changes apply live.
fonts
6 display font families are available for the dashboard's headings and logos.
density
three layout density modes: compact, default, and spacious. adjusts padding and spacing across the dashboard.
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,
"allowedBots": { "names": ["Twitterbot"], "uaStrings": [] }
}
}
POST/api/sites/:id/domain
change the domain for a site. automatically rotates the site key — all existing visitor tokens are invalidated. returns the new domain and key.
| field | description |
|---|---|
| domainrequired | new domain. protocol and path are stripped automatically. |
{ "domain": "newdomain.com", "key": "bw_live_..." }
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. returns {"key": "bw_live_..."}.
GET/api/sites/:id/requests
returns the last 500 verification attempts for the site, newest first. each request includes id, country, detected, status, ts, and ua.
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. performs bot allowlist check, crawler detection, tor/vpn blocking, and headless detection before issuing a challenge. returns a challenge ID and difficulty, or skips straight to a token for allowlisted/crawler traffic.
| field | description |
|---|---|
| siteKeyrequired | the bw_live_... key from the embed script |
| returnUrloptional | url to redirect to after challenge passes |
the response also includes a challengeUi object with the site's challenge page customization settings.
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 |
blog api
public read endpoints require no authentication. write endpoints require the x-admin-key header with the server's ADMIN_KEY environment variable.
GET/api/blog
returns all posts sorted by publish date descending. each item includes id, title, slug, excerpt, and publishedAt (unix ms). does not include full content — use the single-post endpoint for that.
GET/api/blog/:slug
returns a single post by its slug. includes content (raw markdown) in addition to all list fields. 404 if not found.
POST/api/admin/blog
create a new blog post. requires x-admin-key header. the slug is auto-generated from the title. the excerpt is auto-generated from the first ~220 chars of content (markdown stripped).
| field | description |
|---|---|
| titlerequired | post title. used to generate the slug. |
| contentrequired | post body in markdown. stored as-is and rendered client-side. |
returns {"id", "slug", "publishedAt"} on success. 409 if a post with the same generated slug already exists.
DELETE/api/admin/blog/:id
permanently delete a blog post by id. requires x-admin-key header.
GET/rss.xml
RSS 2.0 feed of all blog posts. includes full post content in <content:encoded> — RSS readers that support it will display the full post inline. updated on every request.
/rss.xml from your blog page and add a <link rel="alternate"> tag in your head so feed readers can auto-discover it.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.
request detail
clicking any row in the overview or all requests table opens a detail drawer on the right side of the dashboard.
the drawer shows: status, country, relative time and full timestamp, a short ref ID (first segment of the internal UUID), detected type with a colored badge, and the parsed client breakdown.
client parsing
the raw user agent string is parsed into human-readable fields client-side — no server call needed:
| field | description |
|---|---|
| browser | browser name and major version, e.g. Chrome 120, Firefox 121, Headless Chrome |
| os | operating system and version, e.g. macOS 14.2, Android 13, Windows 10/11 |
| device type | one of desktop, mobile, tablet, or bot |
the raw UA string is shown at the bottom of the drawer in a monospace block. IP addresses and other sensitive fields are never shown.
close with ×, Escape, clicking the backdrop, or navigating to another panel.
detection
brickwall runs several detection checks at challenge init time. the detected type is stored and shown in the request log and detail drawer.
detection priority
checks run in this order, stopping at the first match:
| priority | type | action |
|---|---|---|
| 1 | bot allowlist match | auto-pass, logged as allowed |
| 2 | known crawler (if allowCrawlers on) | auto-pass, logged as crawler |
| 3 | tor exit node (if blockTor on) | blocked before challenge, logged as tor |
| 4 | datacenter/vpn ip (if blockVpn on) | blocked before challenge, logged as datacenter |
| 5 | headless browser UA | challenge served with higher difficulty (6 zeros vs 4), flagged as headless on verify |
| — | none matched | normal challenge, N/A in log |
country lookup
country is resolved from the visitor's IP using geoip-lite — a bundled MaxMind GeoLite2 database. lookups are fully local, no external API calls. if geoip-lite isn't installed, country falls back to Unknown.
headless signals
the following UA substrings trigger headless detection: HeadlessChrome, PhantomJS, Playwright, Puppeteer, selenium, webdriver, python-requests, curl/, wget/, node-fetch, axios/, scrapy, and ~15 more.
edge cases
| scenario | behavior |
|---|---|
| bot allowlist match | skips challenge entirely. auto-passed and logged as "allowed". highest priority check. |
| 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 | blocked at init if "block vpn" is on. org field checked against known datacenter asns. |
| headless chrome / puppeteer | ua detection triggers at init. served harder challenge (6 zeros). flagged on verify. |
| 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. |
| domain migrated | site key is rotated automatically. old tokens are invalidated. update your embed script. |
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, or after a domain migration that rotated the key. 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.
country shows as "unknown"
geoip-lite is not installed. run npm install geoip-lite in your server directory and restart. the package bundles the MaxMind GeoLite2 database (~40MB) locally — no api key needed.
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.