Documentation menuRead guard for data APIs

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:

  1. A browser without a clearance gets 401 {"error":"read_challenge_required"}.
  2. The browser helper gets a fresh token for the read action and posts it to /__blockalt/session. The guard verifies it with siteverify and sets a short, HttpOnly clearance cookie.
  3. 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:

TypeScript
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 Request passed to your handler for caching and conditional requests.
  • Pass stream: true for 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's cancel callback.
  • Keep ordinary responses bounded: cap rows, ranges, exports and query time in your application.
  • Only GET and HEAD are protected; mutations and WebSockets need their own integration. A clearance never replaces account authorisation.

Browser #

JavaScript
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:

Settingdashboard (default)apistrict
Clearance lifetime300 s120 s60 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 network1264
Concurrent handlers / streams per network48 / 416 / 28 / 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-store and override CDN-Cache-Control, Surrogate-Control and every *-CDN-Cache-Control your 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 (its take must be atomic, for example a Redis script) and the same stateSecret everywhere; 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.