getting started

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:

html
<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.

once you see requests appearing in your dashboard's all requests panel, everything is working.
getting started

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.

tokens are scoped per site key. a token from one site cannot be used on another, even if both are registered to the same account.
getting started

installation

script tag (recommended)

place this in <head> before other scripts. it must load synchronously to prevent unprotected content from flashing.

html
<head>
  <!-- brickwall — place before other scripts -->
  <script src="https://brickwall.onrender.com/js/protect.min.js"
          data-site="bw_live_xxxxxxxxxxxx"></script>
</head>

attributes

attributedescription
data-siterequiredyour site key from the dashboard. starts with bw_live_.
data-apioptionaloverride the api base url. defaults to https://brickwall.onrender.com. useful if self-hosting.
data-challengeoptionaloverride 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.

stale key warning: if you see "unknown site key" in the challenge page, your embed script has an old key. rotate and copy the current key from your dashboard's installation panel and update the data-site attribute.
configuration

site settings

each site has independent settings accessible from the dashboard under site settings.

settingdefaultdescription
allowCrawlerstrueautomatically pass googlebot, bingbot, and other known crawlers. logged as "crawler / passed".
blockTorfalseblock known tor exit node ip ranges before the challenge. blocked visitors see an access denied page.
blockVpnfalseflag or block known datacenter asns and vpn exit ip ranges using geoip org lookup.
challengeTtl24how many hours a verified token lasts. after this, visitors are re-challenged.
activetruewhen 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.
configuration

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.

token expiry is checked client-side by decoding the JWT payload — no server call required. the token is only validated server-side during the initial challenge flow.
configuration

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.

both lists are checked independently — a request passes if it matches either a name or a ua string entry.

settings shape

json — settings.allowedBots
{
  "names": ["Twitterbot", "UptimeRobot", "GPTBot"],
  "uaStrings": ["MyInternalTool/2.0"]
}
configuration

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:

terminal
$ 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.

rotating the key on domain change is intentional — the old embed script pointed to the old domain. all existing visitor tokens are immediately invalidated. update your embed script right after migrating.

api

domain migration is also available via the API — see POST /api/sites/:id/domain.

configuration

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.

variabledescription
bgpage background color
accentspinner, progress bar, and highlights
surfacelog panel background
textheadline text color
text2secondary label color
mutedsubline and log entry text
borderpanel borders
border2ring 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

json — settings.challengeUi
{
  "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
}
configuration

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.

dashboard customization is per-browser, not per-account. clearing localStorage will reset to defaults.
api reference

authentication api

sessions use http-only cookies (bw_session). no bearer tokens needed for dashboard api calls.

POST/api/auth/register

fieldtypedescription
namerequiredstringdisplay name
emailrequiredstringmust be unique
passwordrequiredstringminimum 8 characters

returns {"ok": true, "name": "..."} and sets bw_session cookie.

POST/api/auth/login

fieldtypedescription
emailrequiredstring
passwordrequiredstring

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.

api reference

sites api

all endpoints require an active session cookie.

GET/api/sites

returns array of sites belonging to the current user.

POST/api/sites

fielddescription
namerequireddisplay name for the site
domainrequireddomain 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.

json
{
  "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.

fielddescription
domainrequirednew domain. protocol and path are stripped automatically.
json — response
{ "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.

api reference

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.

fielddescription
siteKeyrequiredthe bw_live_... key from the embed script
returnUrloptionalurl 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.

fielddescription
challengeIdrequiredid from /init
noncerequiredthe nonce that produces a hash with the required leading zeros
elapsedrequiredmilliseconds 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.

fielddescription
tokenrequiredthe JWT from localStorage / the bw_token url param
siteKeyrequiredyour site key
api reference

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).

fielddescription
titlerequiredpost title. used to generate the slug.
contentrequiredpost 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.

link to /rss.xml from your blog page and add a <link rel="alternate"> tag in your head so feed readers can auto-discover it.
guides

neocities / static sites

brickwall was built with static hosts in mind. there's no server to configure — just paste the script tag.

html
<!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.

if you have many pages, add the script to a shared header/template if your site uses one. otherwise add it to each file individually.
guides

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.

html — index.html
<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.

guides

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.

javascript — express middleware
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:

javascript — client
const token = localStorage.getItem('bw_token_YOUR_SITE_KEY')
fetch('/api/protected-route', {
  headers: { 'x-brickwall-token': token }
})
guides

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.

there's no limit on the number of sites per account during early access.
reference

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:

fielddescription
browserbrowser name and major version, e.g. Chrome 120, Firefox 121, Headless Chrome
osoperating system and version, e.g. macOS 14.2, Android 13, Windows 10/11
device typeone 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.

reference

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:

prioritytypeaction
1bot allowlist matchauto-pass, logged as allowed
2known crawler (if allowCrawlers on)auto-pass, logged as crawler
3tor exit node (if blockTor on)blocked before challenge, logged as tor
4datacenter/vpn ip (if blockVpn on)blocked before challenge, logged as datacenter
5headless browser UAchallenge served with higher difficulty (6 zeros vs 4), flagged as headless on verify
none matchednormal 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.

reference

edge cases

scenariobehavior
bot allowlist matchskips challenge entirely. auto-passed and logged as "allowed". highest priority check.
googlebot / bingbotauto-bypassed if "allow crawlers" is on. logged as crawler/passed. token issued immediately.
tor exit nodesblocked before challenge if "block tor" is on. blocked visitors see an access denied screen.
vpn / datacenter ipsblocked at init if "block vpn" is on. org field checked against known datacenter asns.
headless chrome / puppeteerua detection triggers at init. served harder challenge (6 zeros). flagged on verify.
javascript disablednoscript fallback message shown. content is not accessible without js.
pow solved in <200msrejected 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 expiredcleared 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 inactiveall visitors pass through without a challenge.
domain migratedsite key is rotated automatically. old tokens are invalidated. update your embed script.
reference

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.