Edge-first
Web Standard Request / Response. Runs on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node 20+.
EdgeShield is an edge-native security toolkit for modern TypeScript runtimes. Compose rate limiting, bot detection, and CSRF guards with pluggable storage — no Node.js APIs required.
Web Standard Request / Response. Runs on Cloudflare Workers, Vercel Edge, Deno, Bun, and Node 20+.
Chain rateLimit, botGuard, and csrfGuard via middleware or presets.
Memory, Upstash, Cloudflare KV, Vercel KV, and Deno KV adapters with a shared conformance suite in CI.
npm install edgeshield
Import only the subpaths you need — the package is tree-shakeable and ships zero runtime dependencies.
Rate-limit incoming requests with an in-memory store (swap in KV/Redis for production):
import { rateLimit, slidingWindow } from "edgeshield/ratelimit";
import { memory } from "edgeshield/storage/memory";
const limiter = rateLimit({
storage: memory(),
algorithm: slidingWindow(100, "15m")
});
const result = await limiter.check(request);
if (!result.success) {
return new Response("Too Many Requests", {
status: 429,
headers: result.headers
});
}
memory() with cloudflareKV, upstash, vercelKV, or denoKV so counters survive cold starts and scale across regions.
| Runtime | CI | Notes |
|---|---|---|
| Node.js 20 / 22 | test | Lint, typecheck, coverage, build, bundle size check |
| Bun | bun | bun run test against source |
| Deno 2.x | deno | Smoke tests against built dist/ |
| Cloudflare Workers | — | cloudflareKV adapter |
| Vercel Edge | — | vercelKV adapter |
EdgeShield supports sliding and fixed window algorithms with configurable limits, windows, and key prefixes.
import { rateLimit, slidingWindow, fixedWindow } from "edgeshield/ratelimit";
import { upstash } from "edgeshield/storage/upstash";
const limiter = rateLimit({
storage: upstash({ url: env.UPSTASH_URL, token: env.UPSTASH_TOKEN }),
prefix: "edgeshield:api",
algorithm: slidingWindow(100, "15m"),
failOpen: true // default — allow traffic if storage throws
});
| Function | Description |
|---|---|
slidingWindow(limit, window) |
Rolling window counter — smoother burst handling |
fixedWindow(limit, window) |
Discrete window buckets — simpler, lower storage cost |
Window strings accept s, m, h, or d suffixes (e.g. "15m", "1h").
Score requests using fingerprint heuristics and optional allow/block rules. Three modes:
| Mode | Behaviour |
|---|---|
detect |
Score only — never blocks; use for logging or metrics |
block |
Return 403 when score exceeds threshold; optional VDF header challenge |
challenge |
Serve HTML Sloth VDF page to suspicious browser clients |
import { botGuard } from "edgeshield/bot";
const guard = botGuard({
mode: "block",
threshold: 60,
rules: {
allow: [/googlebot/i, /bingbot/i],
block: [/curl/i, /python-requests/i, /scrapy/i]
}
});
const bot = await guard.check(request);
if (!bot.success) {
return new Response("Forbidden", { status: 403, headers: bot.headers });
}
In block mode, suspicious clients can be asked to solve a Sloth VDF proof-of-work before retrying. The core implementation is adapted from dignity.js (Apache-2.0).
import { botGuard, VDF } from "edgeshield/bot";
const guard = botGuard({
mode: "block",
threshold: 40,
vdf: { enabled: true, steps: 20, maxAgeMs: 300_000 }
});
const first = await guard.check(request);
if (first.reason === "vdf_challenge_required") {
const challenge = first.headers.get("x-edgeshield-vdf-challenge");
const steps = Number(first.headers.get("x-edgeshield-vdf-steps"));
const challengeHex = challenge?.split(".")[0] ?? "";
const proof = await VDF.compute(challengeHex, steps);
// Retry with:
// x-edgeshield-vdf-challenge: <challenge>
// x-edgeshield-vdf-solution: <proof>
}
Use mode: "challenge" for browser traffic. Suspicious clients receive a self-contained HTML page that runs the VDF client-side and retries automatically.
import { botGuard } from "edgeshield/bot";
import { memory } from "edgeshield/storage/memory";
const guard = botGuard({
mode: "challenge",
threshold: 60,
storage: memory(), // optional: one-time solution anti-replay
vdf: { steps: 20, maxAgeMs: 300_000 },
challenge: {
renderer: (context) => `<html>...</html>` // optional custom HTML
}
});
const result = await guard.check(request);
if (!result.success && result.body) {
return new Response(result.body, {
status: 403,
headers: result.headers
});
}
body is present on the guard result.
Protect state-changing routes with double-submit cookies or origin header verification.
import { csrfGuard } from "edgeshield/csrf";
const csrf = csrfGuard({
mode: "double-submit",
secret: process.env.CSRF_SECRET!,
ttl: "1h",
ignorePaths: ["/api/webhooks/**"]
});
const token = await csrf.generate(request);
const cookie = csrf.buildCookie(token);
const verify = await csrf.verify(request);
if (!verify.valid) {
return new Response("Forbidden", { status: 403 });
}
| Mode | Mechanism |
|---|---|
double-submit |
HMAC-signed token in cookie + header/body field |
origin-check |
Validate Origin / Referer against allowed hosts |
Run multiple guards in sequence. The first failure short-circuits with the appropriate response.
import { createMiddleware } from "edgeshield/middleware/nextjs";
import { rateLimit, slidingWindow } from "edgeshield/ratelimit";
import { botGuard } from "edgeshield/bot";
export default createMiddleware(
rateLimit({ storage, algorithm: slidingWindow(100, "15m") }),
botGuard({ mode: "challenge", threshold: 60 })
);
import { Hono } from "hono";
import { edgeshield } from "edgeshield/middleware/hono";
import { rateLimit, slidingWindow } from "edgeshield/ratelimit";
import { botGuard } from "edgeshield/bot";
const app = new Hono();
app.use(
"/api/*",
edgeshield(
rateLimit({ storage, algorithm: slidingWindow(100, "15m") }),
botGuard({ mode: "block", threshold: 60 })
)
);
import { shield } from "edgeshield/middleware/generic";
import { rateLimit, slidingWindow } from "edgeshield/ratelimit";
import { botGuard } from "edgeshield/bot";
const protect = shield(
rateLimit({ storage, algorithm: slidingWindow(100, "15m") }),
botGuard({ mode: "block", threshold: 60 })
);
const blocked = await protect(request);
if (blocked) return blocked;
Opinionated defaults for common route classes. Import from edgeshield/presets.
import { presets } from "edgeshield/presets";
import { memory } from "edgeshield/storage/memory";
const storage = memory();
// Rate-limit-only presets
const apiLimiter = presets.api({ storage, limit: 100, window: "15m" });
const authLimiter = presets.auth({ storage });
const pageLimiter = presets.page({ storage });
// Composite shields (rate limit + bot + optional CSRF)
const apiShield = presets.apiShield({
storage,
csrfSecret: process.env.CSRF_SECRET
});
const authShield = presets.authShield({
storage,
csrfSecret: process.env.CSRF_SECRET!
});
const pageShield = presets.pageShield({ storage });
| Preset | Default limit | Guards |
|---|---|---|
api |
100 / 15m | Rate limit |
auth |
5 / 15m | Rate limit |
page |
300 / 1m | Rate limit |
apiShield |
100 / 15m | Rate limit + bot block + optional CSRF |
authShield |
5 / 15m | Rate limit + bot block + CSRF (required secret) |
pageShield |
300 / 1m | Rate limit + bot challenge |
All adapters implement the same four-method contract and pass a shared conformance suite in CI.
import { cloudflareKV } from "edgeshield/storage/cloudflare-kv";
const storage = cloudflareKV({ binding: env.EDGE_KV, prefix: "edgeshield" });
import { vercelKV } from "edgeshield/storage/vercel-kv";
const storage = vercelKV({ client: kv, prefix: "edgeshield" });
import { denoKV } from "edgeshield/storage/deno-kv";
const kv = await Deno.openKv();
const storage = denoKV({ kv, prefix: "edgeshield" });
import { upstash } from "edgeshield/storage/upstash";
const storage = upstash({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
import { memory } from "edgeshield/storage/memory";
const storage = memory();
Implement four methods against the shared contract:
import type { StorageAdapter } from "edgeshield";
export function createMyAdapter(client: MyKVClient): StorageAdapter {
return {
get: (key) => client.get(key),
set: (key, value, ttlMs) => client.set(key, value, { px: ttlMs }),
increment: async (key, ttlMs) => {
const next = await client.incr(key);
await client.pexpire(key, ttlMs);
return next;
},
delete: (key) => client.del(key)
};
}
prefix. increment must return the new counter and honour TTL on first write. Expired keys should behave as missing on get.
| Import path | Primary exports |
|---|---|
edgeshield |
Core types, all guards, storage helpers, presets |
edgeshield/ratelimit |
rateLimit, slidingWindow, fixedWindow |
edgeshield/bot |
botGuard, VDF, createVdfChallenge |
edgeshield/csrf |
csrfGuard, verifyOrigin |
edgeshield/storage/* |
memory, upstash, cloudflareKV, vercelKV, denoKV |
edgeshield/middleware/* |
createMiddleware, edgeshield, shield |
edgeshield/presets |
presets.api, apiShield, authShield, pageShield |
Machine-readable metadata: openapi-like.json
| Feature | EdgeShield | @upstash/ratelimit | rate-limiter-flexible | express-rate-limit |
|---|---|---|---|---|
| Edge-native | Yes | Yes | No | No |
| Storage-agnostic | Yes | Upstash only | Redis/Mongo/Postgres | Memory/Redis |
| Bot detection | Yes | No | No | No |
| CSRF protection | Yes | No | No | No |
| Tree-shakeable subpaths | Yes | No | No | No |
| Zero dependencies | Yes | Needs @upstash/redis | 0 deps (core) | 0 deps |
| Bundle size (ratelimit) | < 4 KB gzip (CI enforced) | ~8 KB | ~15 KB | ~5 KB |
# Run tests (181+ vitest + Deno smoke)
npm test
# Coverage
npm run test:coverage
# Build
npm run build
# Bundle size budget
npm run size:check
# Serve docs locally
npm run docs:serve
# Multi-runtime
npm run test:bun
npm run build && npm run test:deno