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 240 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.
Rule updates
New detection rules don't wait for a release. Every install checks LightMoon's signed rule feed (https://waf.lightmoon.org/rules/v1.json) every 5 minutes and applies a new pack as soon as its Ed25519 signature verifies against the key inside the package. It is free and needs no account. The check is a plain download of one public file: it sends nothing about your traffic, and it never holds up a request.
Feed rules run with the built-in ones, so presets, paranoia levels, monitor mode and disabledRules apply to them too. A pack can add rules but can't replace or switch off a built-in one. If an advisory in the feed affects the version you run, LightMoon prints one warning that names the fixed version.
Changes to the engine itself, such as speed-ups, new decoders and bug fixes, still need npm update lightmoon. Renovate or Dependabot can open that pull request for you. To check that an install loads the feed, request /?q=lightmoon-feed-test: it is blocked with rule lm-feed-test. Turn the feed off with updates: false; it is already off when NODE_ENV is test.
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.