Read guard for data APIs
Protect JSON endpoints and server-sent event streams against scraping with short clearances and per-visitor budgets.
On this page
How it works #
Protect the data endpoints themselves, not only the page that shows them. The read guard runs in your server, in front of every protected GET/HEAD route and before any cache:
- A browser without a clearance gets
401 {"error":"read_challenge_required"}. - The browser helper gets a fresh token for the
readaction and posts it to/__blockalt/session. The guard verifies it with siteverify and sets a short, HttpOnly clearance cookie. - Every protected read spends the clearance's and the visitor's budget. Streams are closed when the clearance expires.
A request without a clearance gets no data: a client, with or without JavaScript, must complete the proof of work first. Any client that pays for the work, a browser bot or a script that implements the published protocol, can still read, but only within the budgets: this raises the cost of scraping and caps its volume. It cannot stop copying of data already shown to a visitor.
Server #
Publish a read action with proof of work in the site's settings, install @blockalt/server, then create one guard per origin, shared by every protected route:
import { createReadGuard, createVerifier } from "@blockalt/server";
const guard = createReadGuard({
origin: "https://example.com",
action: "read",
verify: createVerifier({
apiUrl: "https://blockalt.click",
secret: process.env.BLOCKALT_SECRET!,
hostname: "example.com",
action: "read",
}),
profile: "dashboard", // or "api" or "strict"
// Lets every read through, unchecked, while the site is paused or unpaid:
openCheck: {
siteKey: "ba_pk_…",
apiUrl: "https://blockalt.click",
configPublicKeys: ["MCowBQYDK2VwAyEAXmYdcBh/jf+z+0H+hs2QvZ9UG4PBgkRvHX/JuXyjIL8=","MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE34qdEtOeD21+CmmfTmFyRr/vySsY1TD6BJZFtwF37pkelvN2vRYWWHdLrUouzkQXEfnA7V50xK2QcmnBCGsxbA==","MCowBQYDK2VwAyEAtkfghYcdvSRf2wA01gjQtgTqdYGjaDUZsOPXzgLnnT0=","MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYYmQNAMnVKK7G3VVguAk3SC/RFT5SDjCoxcFMOPFx0vmsGycJWOK8hCdxuTCHlvShng1fo0y4Q9PJHPIGQ9EfA=="],
},
});
// In your request dispatcher, before data handlers and caches:
const peer = server.requestIP(request)?.address ?? ""; // the real client address
if (path === "/__blockalt/session") return guard.exchange(request, peer);
if (path === "/api/data") return guard.protect(request, peer, dataHandler);
if (path === "/api/expensive-query") return guard.protect(request, peer, queryHandler, { cost: 10 });
if (path === "/api/live") return guard.protect(request, peer, liveHandler, { stream: true, cost: 5 });- Use the sanitized
Requestpassed to your handler for caching and conditional requests. - Pass
stream: truefor every SSE or streaming body. Streams close at clearance expiry, are limited per visitor, and each started 16 KiB costs one unit. Stop producing in the stream'scancelcallback. - Keep ordinary responses bounded: cap rows, ranges, exports and query time in your application.
- Only
GETandHEADare protected; mutations and WebSockets need their own integration. A clearance never replaces account authorisation.
Browser #
const reader = BlockAlt.createReader({ protection, action: "read" });
const response = await reader.fetch("/api/data");
if (response.status === 429) slowDown();reader.fetch gets a clearance only after a 401 read_challenge_required, shares one pending clearance between concurrent reads, and retries the GET once. It refuses cross-origin requests and mutations, does not patch the global fetch, and never retries 429 or 503 by itself. For HEAD or a native EventSource, call await reader.authorize() first; close an EventSource on error and call reader.invalidate() before reconnecting after a delay.
While the site is paused or unpaid, execute returns an empty token. The reader still exchanges it: the guard asks siteverify and, on paused or unpaid, serves reads unchecked for a short window (openWindowSeconds, 30 by default). With openCheck, the guard also reads the signed configuration for pages that never exchange; since the configuration speaks for the whole site, it then confirms with one empty-token verification of its own action, so a read action your rules block keeps needing a clearance while unpaid.
Profiles #
Budgets count per visitor network (an IPv4 address or an IPv6 /64, shared by everyone behind it) and per clearance. Pick the profile closest to your traffic, then override single values if needed:
| Setting | dashboard (default) | api | strict |
|---|---|---|---|
| Clearance lifetime | 300 s | 120 s | 60 s |
| Reads per minute per network (burst) | 3,000 (600) | 600 (150) | 120 (40) |
| Reads per minute per clearance (burst) | 600 (150) | 240 (60) | 60 (20) |
| Clearance exchanges per minute per network | 12 | 6 | 4 |
| Concurrent handlers / streams per network | 48 / 4 | 16 / 2 | 8 / 1 |
dashboard is sized on a real polling dashboard with live streams; strict suits pages that read rarely. Budgets are continuously refilled token buckets, and renewing a clearance never resets a visitor's quota or open streams.
Rolling out safely #
Start with mode: "report": the guard serves every read it would refuse for a missing clearance or an exhausted budget, and tells you through onDecision, which receives only the phase, the outcome and whether it was enforced: no identity, no IP. Count the outcomes in your metrics, tune the profile, then switch to "enforce" (the default). Origin and method checks are always enforced.
Deployment rules #
- Take the client address from the transport, or from a trusted proxy that overwrites it. Never from arbitrary
X-Forwarded-For, a cookie or a browser-generated id. Keep the backend reachable only through that proxy. - Run the guard before all data paths: API aliases, exports, HTML with embedded data, polling and streams. Bypass or purge CDN caches for these paths, since an edge cache that answers first bypasses any origin middleware. Guarded responses set
Cache-Control: private, no-storeand overrideCDN-Cache-Control,Surrogate-Controland every*-CDN-Cache-Controlyour handler sets (Cloudflare, Vercel). - By default clearances and budgets live in the process, in bounded tables that drop their oldest entries instead of refusing newcomers. With several processes or servers, pass a shared
store(itstakemust be atomic, for example a Redis script) and the samestateSecreteverywhere; concurrency limits stay per process. - Clearances that already exist keep working until they expire after a policy change or key revocation. Use a shorter lifetime when faster revocation matters.
Billing #
Each clearance exchange that BlockAlt accepts is one verification. The reads it covers are checked locally by your server and cost nothing; refused exchanges are free.
Something unclear or missing on this page? Write to support@blockalt.click.