Install
npm install lightmoon@alpha
# pnpm add lightmoon@alpha · yarn add lightmoon@alpha · bun add lightmoon@alpha
LightMoon has no runtime dependencies, ships ESM and CommonJS with types, and runs on Node 20+, Bun, Deno, Cloudflare Workers, Vercel and Netlify.
Frameworks
Every adapter takes the same options, or a WAF instance from createWaf() so several entry points share counters and bans.
| Framework | Import | Setup |
|---|---|---|
| Express | lightmoon/express | app.use(lightmoon(opts)) after express.json() |
| Fastify | lightmoon/fastify | await app.register(lightmoonFastify(opts)) |
| Koa | lightmoon/koa | app.use(lightmoonKoa(opts)) |
| NestJS | lightmoon/nestjs | consumer.apply(lightmoonNest(opts)).forRoutes('*') |
| Hono | lightmoon/hono | app.use('*', lightmoonHono(opts)) |
| Next.js | lightmoon/next | export default lightmoonNext(opts, { next: () => NextResponse.next() }) |
| Nuxt | lightmoon/nuxt | defineEventHandler(lightmoonNuxt(opts)) |
| SvelteKit | lightmoon/sveltekit | export const handle = lightmoonSvelteKit(opts) |
| Astro | lightmoon/astro | export const onRequest = lightmoonAstro(opts) |
| React Router, Remix | lightmoon/react-router | export const middleware = [lightmoonReactRouter(opts)] |
| Cloudflare Workers | lightmoon/cloudflare | lightmoonWorker(handler, (env) => opts) |
| Vercel | lightmoon/vercel | export default lightmoonVercel(opts) |
| Netlify | lightmoon/netlify | export default lightmoonNetlify(opts) |
| AWS Lambda | lightmoon/aws-lambda | export const handler = withLightmoonLambda(fn, opts) |
| Bun | lightmoon/bun | Bun.serve({ fetch: lightmoonBun(handler, opts) }) |
| Deno | lightmoon/deno | Deno.serve(lightmoonDeno(handler, opts)) |
| Any Fetch handler | lightmoon/fetch | withLightmoon(handler, opts) |
Adapters for hapi, Elysia, Oak, AdonisJS, h3, SolidStart, Qwik, TanStack Start, Fresh, Connect and node:http are listed in the README.
Presets
No preset ever shows a CAPTCHA. Start with balanced and move to strict once monitor mode looks clean.
balanced | strict | api | paranoid | |
|---|---|---|---|---|
| Paranoia level | 1 | 2 | 1 | 3 |
| Global rate limit | 300/min | 150/min | 600/min | 90/min |
| Browser checks | ✓ | ✓ | – | ✓ |
| Automated clients | score only | block | score only | block |
| Response masking | – | ✓ | – | ✓ |
OWASP CRS rules
The 218 rules ported from CRS 4.29 ship in a separate entry point, so you only load them if you want them:
import { crsRules } from 'lightmoon/crs';
app.use(lightmoon({ preset: 'balanced', extraRules: crsRules() }));
Rules that fire on ordinary prose are tuned down by default. crsRules({ tuning: false }) restores the original scores.
Custom rules
Rules use the Cloudflare rules language. Actions are block, allow, skip, log, score and challenge.
createWaf({
customRules: [
{ id: 'admin-from-office', action: 'block',
expression: 'starts_with(http.request.uri.path, "/admin") and not ip.src in $office' },
{ id: 'cms-editor', action: 'skip', skip: ['managed-rules'],
expression: 'http.request.uri.path eq "/cms/save" and http.request.method eq "POST"' },
],
lists: { office: ['203.0.113.0/24'] },
});
Sink guards
Signatures guess. Sink guards check. Just before your code runs a query, a shell command, a file read or an outbound request, the guard looks for this request's own input in the finished string and checks whether it stayed a single value.
const guard = waf.sinks(req.lightmoon); // Hono: c.get('lightmoon')
await db.query(guard.sql(sql)); // returns the query, or throws InjectionError (403)
exec(guard.shell(command));
const file = guard.path('/srv/uploads', name); // resolved inside the root, or throws
await fetch(await guard.resolvedUrl(target)); // refuses internal addresses chosen by the request
await users.find(guard.nosql(filter));
Monitor mode and tuning
Deploy with mode: 'monitor' first. Requests that would have been blocked are allowed and reported through onDecision, or to the Cloud dashboard, where the Tuning page turns repeated matches from many different clients into ready-to-paste skip rules. Switch to mode: 'block' when the list is empty.
Connect to Cloud
Create a project in the dashboard and copy its key. It is shown once. Store it as a secret, then wrap your options:
import { createWaf } from 'lightmoon';
import { cloud, withCloud } from 'lightmoon/cloud';
const lm = cloud({ key: process.env.LIGHTMOON_KEY });
const waf = createWaf(withCloud(lm, { preset: 'balanced' }));
app.use(lightmoon(waf));
On Node, events are sent in the background every few seconds. On serverless and edge runtimes, flush after the response so nothing is lost:
ctx.waitUntil(lm.flush()); // Cloudflare Workers, Vercel Edge, Netlify Edge
Remote rules and blocks
Rules, IP and country blocks and fleet bans you add in the dashboard are packed into a rule pack signed with your project's Ed25519 key. The connector pulls it every minute and verifies the signature with the public key embedded in your project key, so a pack that wasn't signed for your project is never applied. If the dashboard can't be reached, instances keep the last pack they verified.
What is sent
For blocked, challenged and monitored requests only: time, request ID, action, reason, status, method, host, path without the query string, matched rule IDs, attack and bot scores, country, a short message and the client IP truncated to /24 (IPv4) or /48 (IPv6). Confirmed injections, response leaks and bans are sent as events too; bans include the banned network so other instances can apply it. Request bodies, headers, cookies, query strings and matched snippets are never sent.
The full reference, with every option, is in the package README.