Documentation

One package, two adapters, about five minutes. It runs inside the application you already have — there is nothing to provision and no DNS to repoint.

Install

You need a site key and a secret. Both come from the setup screen when you register your domain. The secret is shown once and cannot be retrieved afterwards — if you lose it, rotate the key rather than trying to recover it.

terminal
npm install @pharoshub/guard
.env
PHAROS_SITE_KEY=st_your_key_here
PHAROS_SECRET=your_secret_here
PHAROS_COLLECTOR=https://collect.pharoshub.cloud/api/ingest

Treat PHAROS_SECRET like a database password. Anyone holding it can write into your evidence trail.

Express

Mount it before your own routes. Anything mounted earlier answers first, and a trap path that your router handles is a trap that never fires.

server.js
import express from "express";
import { createGuard } from "@pharoshub/guard";

const app = express();

const guard = createGuard({
  siteKey: process.env.PHAROS_SITE_KEY,
  secret:  process.env.PHAROS_SECRET,
});

app.use(guard.middleware());

// ... your own routes below

process.on("SIGTERM", async () => {
  // Flush the last batch instead of losing it on shutdown.
  await guard.flush();
  process.exit(0);
});

Next.js

Next middleware runs on the Edge runtime, where Node’s crypto module does not exist — so the adapter is a separate implementation built on Web Crypto. Import it from @pharoshub/guard/next, not the root.

middleware.ts
import { NextResponse } from "next/server";
import { createNextGuard } from "@pharoshub/guard/next";

const guard = createNextGuard();

export async function middleware(request: Request) {
  // Returns a Response for a trap hit, or null to carry on — so your own
  // routing stays in control and this can never swallow a real request.
  return (await guard(request)) ?? NextResponse.next();
}

export const config = { matcher: "/:path*" };

The clone beacon

A copied page copies this tag with it, so the copy asks us for the script and tells us which host it is serving. Put it in your document head on every public page.

app/layout.tsx
<script
  src={`https://collect.pharoshub.cloud/b/${process.env.PHAROS_SITE_KEY}.js`}
  async
  defer
/>

Running a Content-Security-Policy with a nonce? Pass it through: guard.beaconTag(nonce) returns the whole tag with the nonce applied.

The beacon reads three values that are already public — the hostname, the path, and the referrer — and sends them once. It sets no cookies, reads no storage, and does not fingerprint anyone.

Honeytokens

A credential that looks real and is never valid. Put it where an intruder will only find it by looking somewhere they should not — a stale config file, an HTML comment, a decoy admin page.

anywhere in your app
const token = guard.honeytoken("stripe");
// -> "pk_live_9f2c1a7b4e8d03564a1b8e7c2d905f13"

Nobody legitimate holds one, so its use is not a signal to weigh. It is proof.

Tuning trap paths

Twelve sensible defaults ship with the package — /.env, /wp-admin, /backup.zip and similar. The best traps, though, are the ones specific to you: a path that looks like it belongs to your stack and that no link points at.

server.js
import { createGuard, DEFAULT_TRAPS } from "@pharoshub/guard";

const guard = createGuard({
  siteKey: process.env.PHAROS_SITE_KEY,
  secret:  process.env.PHAROS_SECRET,
  traps: [...DEFAULT_TRAPS, "/staff-portal", "/booking-export.csv"],
});

Your plan sets how many you may run — 12 on Watch, 40 on Guard. Make sure a real visitor can never reach one by following a link, or you will be investigating your own marketing team.

Sign-in reporting

Credential stuffing is invisible in any single request — it is a shape across many, and that shape only becomes readable if outcomes are reported. Call this from your own sign-in handler on Guard and above.

your login route
import { createHash } from "node:crypto";

// YOU hash the identifier. We never receive an email address, and there is
// no parameter for anything password-derived.
const accountHash = createHash("sha256")
  .update(email.toLowerCase() + process.env.MY_SALT)
  .digest("hex");

guard.reportAuth({ accountHash, success: false, req });

Because the hash is yours and salted with your own secret, we can tell you that one address failed against forty accounts without ever being able to work out whose. That is what makes it safe for us to hold at all.

Three shapes become detectable once this is wired: spraying (one source, many accounts), stuffing (many sources, one credential list), and takeover — failures against an account followed by a success from an address that has never signed into it before, which is the one that has already happened.

What it sends

Only requests that were already suspicious. A normal request never reaches the reporting path at all.

FieldExample
path/.env
methodGET
source_ip203.0.113.9
user_agentpython-requests/2.31
referer
observed_at2026-08-16T03:14:02Z

Request bodies are not captured unless you pass captureBodies: true, and even then only for trap routes. A body on your real routes contains your guests’ details, and those are yours.

How it fails

This runs in front of a booking flow. If it throws, hangs, or slows a request, it has done more damage than the attacks it detects — so every failure mode below is a structural property of the package rather than a policy.

  • Missing key or secretThe guard becomes a no-op and says so once at startup.
  • Collector unreachableEvents queue in memory, capped at 500 with the oldest dropped, and retry.
  • Subscription lapsedThe collector answers 402. The package stops retrying rather than hammering us for a month.
  • Anything throwsThe request continues exactly as if the guard were not installed.

The worst outcome of a broken guard is missing telemetry. It is never a broken site.

Troubleshooting

+ The setup screen never turns green

The guard only reports when something suspicious happens, plus a heartbeat. Request one of your own trap paths — curl https://yoursite.com/.env — and it should flip within a minute. If it does not, check that the middleware is mounted before your routes.

+ I get 401 unauthorised in my logs

The signature covers the exact request body, so a proxy that rewrites bodies will break it. Also check your server clock: reports more than five minutes out of step are refused, and that shows up as 408 rather than 401.

+ A trap path is firing on real visitors

Something links to it. Search your own markup and your sitemap. A trap a visitor can reach by clicking is not a trap.

+ I lost the secret

Rotate the site key from the Protection screen and update your environment. There is no recovery path, by design — a credential that can write into an evidence trail should not be recoverable by anyone who can read a support ticket.

Stuck on something not covered here? Email support@pharoshub.cloud — a person who has read the code will answer.