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://www.pharoshub.cloud/api/ingest

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

Hallmark — sealing your pages

Hallmark signs your site when you build it, and every visitor’s browser checks that signature against what was actually delivered. It answers the one question no firewall can: has anything on my site been changed since I published it?

The realistic attack is not defacement. It is one line — a reservations number swapped for someone else’s, a payment block added to a page, two digits changed in an account number. The page loads perfectly and every request is legitimate, so nothing else in a normal security stack is looking.

Add it to your build, after your site is generated:

build.mjs
import { hallmark } from "@pharoshub/guard/hallmark";

await hallmark({ dir: "dist" });   // or "out", "build", ".output/public"

It fingerprints every published file, asks us to sign the list, and writes two files into your output: _integrity.json (the signed list) and integrity.js (the verifier). Your CDN serves both like any other file. The verifier is written before fingerprinting, so it is sealed too — a verifier outside the seal is one an attacker could quietly replace with a no-op.

Then load the verifier on your pages:

your layout
<script src="/integrity.js" defer></script>

What leaves your infrastructure

A list of paths and SHA-256 fingerprints. Never the files themselves. Your build hashes its own output, so your site never reaches us, cannot be exfiltrated here, and a large site costs no more to seal than a small one.

Why we can make this promise

The signing key is not on your servers, not in your build, not at your CDN, and not in our database. Someone who takes over all of it can change whatever they like — they cannot produce a signature that verifies. Verification uses a public key that can only check, never sign, so anyone can audit a page independently without an account.

What gets checked

The HTML document, plus the same-origin scripts and stylesheets that page actually loaded. Scripts matter as much as markup: a swapped JavaScript bundle is how card skimming is done, and checking only the document would miss it entirely.

Third-party scripts are deliberately not checked. They are not yours to seal, they change without warning, and flagging them would produce findings you can neither explain nor fix.

It reports; it does not block

A mismatch is usually innocent — a corporate proxy that rewrites HTML, an antivirus, an ISP banner, or a deploy landing while somebody had the old page open. So Hallmark reports and never stops a page loading for your visitors. What distinguishes an attack is scale: one visitor is their network, thousands of visitors across unrelated networks is the file. You are emailed once a finding passes that threshold, not on the first report.

When it stops

The seal carries an expiry inside the signature, set from your subscription plus a fortnight’s grace. It cannot be extended by editing the file — that breaks the signature. Renewing and redeploying reseals; renewing without deploying reseals too.

If it cannot seal

Every failure is open. No keys, no network, our service down, subscription lapsed — the build prints a warning and finishes. A security feature that can stop you deploying is a worse problem than the one it solves. Check your build log for sealed N files; anything else prints the reason.

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://www.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.

The same honesty about your own tooling: an uptime monitor that crawls every path, a vulnerability scanner you run, or a penetration test you commission will spring traps exactly as an intruder would — that is the traps working, not failing. Point your own tools away from trap paths where you can, and when you cannot, close those incidents as your own from the console; the distinction is recorded, which matters if a real alert is ever questioned later.

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.