Every claim on this page is proven, not asserted.Each defense names the file it lives in and shows the load-bearing code inline. We mirror the same "claim, formula, rationale" pattern used on the FlareWatch Score methodology page so that an outside auditor, a delegator, or a future maintainer can examine the security posture without having to take our word for it.
The companion document below carries the long-form rationale plus an eight-step publicly-observable verification checklist that anyone can run against the live site from a browser DevTools panel or terminal — without needing source access.
Open the 2026-05-18 audit-evidence document →Source-code access: the FlareWatch repository is currently private. The code snippets below are the exact text that ships to production. Serious security researchers, grant reviewers, journalists, or auditors who want full repo read access can request it by emailing hello@flarewatch.io.
Threat model
FlareWatch is a read-only monitoring and tracking tool. It does not hold user keys, does not sign transactions, does not custody assets, and does not interact with smart contracts on a user's behalf. It cannot move funds under any circumstance.
The wallet connection (Sign-In-With-Ethereum) exists solely to identify which on-chain address to query. This is the critical scoping fact: the maximum blast radius of any successful attack against FlareWatch is "an attacker reads another user's tracked-wallet metadata" — notification preferences, tax rate, watched-wallet list. No FlareWatch vulnerability can result in stolen funds, because FlareWatch has no access to funds to steal.
This threat model shapes every defensive choice that follows. Out of scope: a compromised user wallet (the SIWE signature would be valid — that's upstream of us); browser-side malware on the user's device; phishing sites impersonating FlareWatch (mitigated by domain registration, HSTS preload, and CAA records, but ultimately a browser-level problem).
Verifying posts that claim to be FlareWatch
Crypto Twitter is a scam target. Fake FlareWatch accounts and impersonation posts will appear. To make impersonation surface-checkable at every literacy level, every legitimate FlareWatch post — on Twitter, LinkedIn, or Mastodon — ends with a unique URL on our domain:
https://flarewatch.io/p/<slug>
Click it. You land on a verification page on flarewatch.ioshowing the canonical post body, metadata, and a verified/fake banner derived from the post's ed25519 signature. The page also surfaces the link to the live post on its source channel so you can cross-check the account that published it.
Posts claiming to be FlareWatch without this URL are fabricated. Report and don't engage. We will never DM first, never ask for seed phrases, and never run airdrops that require sending funds.
Browse the live registry of every verified post at flarewatch.io/flare-feed, which also carries a paste-a-URL widget for inline checking. The registry is also exposed as a public JSON feed at /api/posts/registry and RSS at /api/posts/rss for community scam-monitor bots.
Authentication — Sign-In With Ethereum
FlareWatch uses Sign-In-With-Ethereum (SIWE, EIP-4361). The user signs a structured message with their wallet; the server verifies the signature and issues a short-lived session cookie. There are no passwords.
services/auth/siweVerify.ts (canonical constant + equality check at line 87)// services/auth/siweVerify.ts
const FLAREWATCH_SIWE_STATEMENT =
"Sign in to FlareWatch. By signing, you confirm ...";
// During verification:
if (parsed.statement !== FLAREWATCH_SIWE_STATEMENT) {
return fail('statement_mismatch', parsed.address);
}FLAREWATCH_SIWE_STATEMENTconstant — it's a literal string, not derived from request input. Trace the equality check at the top of verifyMessage().expirationTime, must declare chainId = 14 (Flare mainnet), and must claim a domain matching our environment-pinned allowlist. Any deviation rejects the message before signature verification runs.services/auth/siweVerify.ts (lines 75–95 expiration; lines 32–73 domain; line 66 chain ID)// services/auth/siweVerify.ts
if (!parsed.expirationTime) {
return fail('expiration_required', parsed.address);
}
const exp = new Date(parsed.expirationTime).getTime();
if (exp <= now) {
return fail('message_expired', parsed.address);
}
// Chain ID and domain checks happen before signature verificationSIWE_ALLOWED_DOMAINS is read from env and rejected messages do not advance to signature verification. Try signing a SIWE message with chainId: 1— it'll fail immediately.services/auth/nonce.ts (full file — TTL constant + getdel call)// services/auth/nonce.ts
const NONCE_TTL_SECONDS = 300; // 5 minutes
export async function storeNonce(address: string, nonce: string): Promise<void> {
await kv.set(nonceKey(address), nonce, { ex: NONCE_TTL_SECONDS });
}
export async function consumeNonce(address: string): Promise<string | null> {
return kv.getdel<string>(nonceKey(address)); // <-- single op, atomic
}consumeNonce(). Confirm it uses kv.getdel (one round-trip, returns the previous value AND deletes the key) rather than the sequence-prone kv.get() + kv.del().services/auth/siweVerify.ts (verify call at ~line 113, consume at ~line 126)// Order matters:
const sigValid = await verifySignature(parsed, signature);
if (!sigValid) return fail('bad_signature', parsed.address);
// Only AFTER signature is valid do we burn the nonce
const consumedNonce = await consumeNonce(parsed.address);
if (consumedNonce !== parsed.nonce) {
return fail('nonce_mismatch', parsed.address);
}verifyMessage(). The signature-verification call sits ahead of the consumeNonce() call.Sessions
Sessions are JWT-based, signed with HMAC-SHA256, and issued after a successful SIWE signature. Issuer and audience claims are validated on every verification. Each token carries a unique JTI that enables fine-grained per-session revocation.
__Host- prefix in production. The browser refuses to set Domain on a __Host- cookie, eliminating a class of subdomain cookie-fixation attacks. HttpOnly blocks JavaScript from reading the cookie (which makes XSS-based session theft impossible).services/auth/jwt.ts (cookie configuration lines 18–58)// Auth cookie (24-hour access token):
{
name: process.env.NODE_ENV === 'production' ? '__Host-fw-auth' : 'fw-auth',
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'lax', // top-level GETs ok, blocks CSRF POSTs
path: '/',
}
// Refresh cookie (7-day): same shape, sameSite: 'strict' (no cross-site at all)HttpOnly ✓, Secure ✓, SameSite=Lax, and the __Host- prefix.HttpOnly kills XSS theft, Secure kills plaintext leak, SameSite kills CSRF, __Host-kills subdomain-fixation. None of them on its own is sufficient; all four together close every cookie-handling attack class we're aware of.services/auth/sessions.ts (revokeSession at lines 59–78)// services/auth/sessions.ts
export async function revokeSession(jti: string): Promise<void> {
// Tombstone: keep the record but flag revoked=true so a later
// race condition can still detect the revocation event in audit.
await kv.set(sessionKey(jti), { revoked: true, ts: Date.now() }, { ex: TTL });
}
// On every authenticated request:
const record = await kv.get<SessionRecord>(sessionKey(jti));
if (record?.revoked) return null; // session rejectedcrypto.timingSafeEqual.services/auth/quickUnlock.ts (constants at lines 47–49, hash at line 152, verify at line 158)// services/auth/quickUnlock.ts
const PBKDF2_ITERATIONS = 310_000; // OWASP 2023 recommendation
const MAX_PIN_ATTEMPTS = 5;
const LOCKOUT_DURATION_SECONDS = 900; // 15 minutes
function hashPin(pin: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(pin, salt, PBKDF2_ITERATIONS, 32, 'sha256');
}
// During verify:
if (!crypto.timingSafeEqual(computed, stored)) {
attempts++;
if (attempts >= MAX_PIN_ATTEMPTS) {
await kv.set(lockoutKey, true, { ex: LOCKOUT_DURATION_SECONDS });
}
return false;
}Per-wallet ownership gating
services/auth/requireWalletOwnership.ts (lines 26–60)// services/auth/requireWalletOwnership.ts
export async function requireWalletOwnership(
targetWallet: string | undefined | null
): Promise<
| { ok: true; session: { address: string } }
| { ok: false; status: 401 | 403; message: string }
> {
if (!targetWallet || typeof targetWallet !== 'string') {
return { ok: false, status: 401, message: 'targetWallet required' };
}
const session = await getAuthSession();
if (!session || session.address == null) {
return { ok: false, status: 401, message: 'not signed in' };
}
const target = targetWallet.toLowerCase();
const primary = session.address.toLowerCase();
if (primary === target) {
return { ok: true, session: { address: session.address } };
}
// Honor attested ownership: the primary may access a wallet it has attested.
try {
const record = await kv.get<PersistedWallet>(`wallet:${primary}`);
const attested = (record?.watchedWallets ?? []).some(
(w) =>
w.cChainAddress.toLowerCase() === target && w.ownership === 'attested',
);
if (attested) {
return { ok: true, session: { address: session.address } };
}
} catch {
// Fail closed — fall through to the 403 below.
}
return { ok: false, status: 403, message: 'wallet ownership mismatch' };
}/api/wallet/<wallet-B-address>/preferences. You receive 403, not 200 — unless wallet B previously signed an attestation while on wallet A's watched list, in which case wallet B's own signature is what authorised the access. Both addresses are normalised with .toLowerCase()before comparison so mixed-case spellings can't bypass the check.Admin surface
/api/admin/* routes and the /admin page require an admin-wallet session. The allowlist (ADMIN_WALLETS) lives in source code. Granting a new admin requires a code change and deploy.services/auth/requireAdminWallet.ts (lines 24–42)// services/auth/requireAdminWallet.ts
export async function requireAdminWallet(): Promise<...> {
const session = await getAuthSession();
if (!session || session.address == null) {
return { ok: false, status: 401, message: 'not signed in' };
}
const signedIn = session.address.toLowerCase();
const isAdmin = (ADMIN_WALLETS as readonly string[]).some(
(w) => w.toLowerCase() === signedIn,
);
if (!isAdmin) {
return { ok: false, status: 403, message: 'not an admin wallet' };
}
return { ok: true, session: { address: session.address } };
}constants/wallets.ts — the allowlist values are public. Try hitting any /api/admin/* route from a non-allowlisted wallet. You get 403.Cron-route authentication
/api/cron/* rejects requests without a valid Authorization: Bearer ${CRON_SECRET} header. Comparison is length-safe and timing-safe. Confirmed coverage as of 2026-07-04: all 27 cron routes call validateCronAuth().lib/cronAuth.ts (timingSafeEqual at the bottom of validateCronAuth)// lib/cronAuth.ts — every cron route calls this first
const authHeader = request.headers.get("authorization");
const expected = `Bearer ${cronSecret}`;
const headerBuf = Buffer.from(authHeader ?? "", "utf8");
const expectedBuf = Buffer.from(expected, "utf8");
if (
headerBuf.length !== expectedBuf.length ||
!timingSafeEqual(headerBuf, expectedBuf)
) {
return new Response('{"error":"Unauthorized"}', { status: 401 });
}for f in app/api/cron/*/route.ts; do grep -q "validateCronAuth" "$f" || echo "MISSING: $f"; done. The output should be empty (no missing routes).HTTP security headers
The following headers ship on every response:
- Strict-Transport-Security —
max-age=31536000; includeSubDomains; preload. Forces HTTPS for 1 year and the domain is submitted to the browser HSTS preload list. - X-Frame-Options: DENY — blocks all iframe embedding (clickjacking defence layer 1).
- CSP frame-ancestors: 'none' — clickjacking defence layer 2, independent of X-Frame-Options.
- X-Content-Type-Options: nosniff — disables MIME-type sniffing, blocks polyglot file attacks.
- Referrer-Policy: strict-origin-when-cross-origin — strips URL paths from cross-origin Referer headers.
- Permissions-Policy — 22 browser features explicitly denied (camera, microphone, geolocation, payment, USB, etc.).
- X-Permitted-Cross-Domain-Policies: none — closes legacy Flash/Acrobat/Silverlight cross-domain access.
Verify yourself (no source access needed): curl -sI https://flarewatch.io | grep -iE 'strict-transport|x-frame|x-content|referrer|permissions|cross-origin'. Every header above should appear in the response.
Content Security Policy — honest disclosure
The CSP enumerates allowed origins for scripts, connections, frames, fonts, images, and styles. Every third-party origin we talk to (WalletConnect relayer, TradingView S3 host, RPC endpoints) is listed explicitly; no wildcards for external origins.
script-src directive allows inline scripts and eval(). This is deliberate and necessary for the site to function. If the rest of this page implied otherwise, it was wrong; this section corrects it.next.config.mjs (CSP directives lines 57–87)// next.config.mjs — actual production CSP fragment script-src 'self' 'unsafe-inline' // React streaming-SSR hydration emits inline scripts 'unsafe-eval' // WalletConnect uses eval(); TradingView uses Function() https://s3.tradingview.com ...
curl -sI https://flarewatch.io | grep -i 'content-security-policy' and read the script-src directive. Both 'unsafe-inline' and 'unsafe-eval' are present.<script> tags in that HTML willexecute. CSP is not preventing inline XSS on this site. Defence remaining: React's automatic JSX escaping (every user-controlled string is escaped by default), the codebase's zero uses of dangerouslySetInnerHTML with user content, weekly Dependabot scans with high/critical advisories blocking deploys, and the absence of any runtime script-injection surface (the bundle is built by Vercel from a verified GitHub commit). We will not adopt strict-CSP-via-nonce until React, WalletConnect, and TradingView all support it natively — patching three load-bearing dependencies for a defence we already cover otherwise is not a worthwhile tradeoff.Audit logging — privacy and retention
services/auth/auditLog.ts (truncateIp at lines 22–29, extractAuditContext at lines 33–40)// services/auth/auditLog.ts
const TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
const MAX_EVENTS = 50; // ring buffer per wallet
function truncateIp(ip: string): string {
const v4Match = ip.match(/^(\d+\.\d+)\.\d+\.\d+/);
if (v4Match) return `${v4Match[1]}.*.*`; // /16 CIDR for IPv4
const parts = ip.split(":");
if (parts.length >= 4) return parts.slice(0, 4).join(":") + "::"; // /64 for IPv6
return "unknown";
}
export function extractAuditContext(request: Request) {
const rawUa = request.headers.get("user-agent") || "unknown";
return {
ip: truncateIp(rawIp),
ua: rawUa.length > 80 ? rawUa.slice(0, 80) : rawUa,
};
}your-prefix.*.*, not the full address. The UA string is truncated to browser-family-and-OS detail only — not enough to fingerprint a specific extension stack or minor version.Rate limiting
Every authentication endpoint, every per-wallet route, and every cron-diagnostic route is rate-limited per source IP. Limits are tighter on auth endpoints because the threat model on auth includes brute-force and credential-stuffing attempts.
- auth:nonce, auth:verify — 5 requests / minute / IP. Lowest in the system.
- auth:logout, auth:me, auth:audit-log — 60 requests / minute / IP.
- mirror-attribution, epoch-reward-attribution — 60 requests / minute / IP. Per-tx attribution lookups.
- mirror-backfill-progress, validator-mirror-stats — 600 requests / minute / IP. Polling-heavy KV reads.
Verify yourself (no source access needed): hammer /api/auth/noncefrom one IP. After about 5 requests in a minute you'll get a 429.
Failure mode: if KV is unreachable, the rate-limit check fails open (logs a warning, permits the request). Sensitive operations downstream (signature verification, ownership checks) do not fail open — they require KV to verify nonce consumption and session validity.
Deliberate tradeoffs
Five accepted-by-design choices an outside auditor would notice in the headers or the code. We document each one openly so reviewers can verify our reasoning without having to ask us.
- CSP allows 'unsafe-inline' and 'unsafe-eval'. Detailed above. React hydration + WalletConnect + TradingView require them; removing them would break the application.
- COOP / CORP same-origin headers are NOT set.Those values break WalletConnect wallet-popup window handshakes, TradingView's iframe embed, and standard wallet flows for MetaMask / Trust / Rabby. Clickjacking defence is preserved through X-Frame-Options DENY + CSP frame-ancestors 'none' — two independent layers, neither of which depends on cross-origin isolation.
- No CSRF tokens. The SIWE wallet signature IS the auth proof. State-changing operations require an authenticated session (gated by SIWE) plus per-wallet ownership verification plus rate limiting. A cross-site forgery would have to first obtain a valid SIWE signature for the target wallet — which is what we defend against above.
- Admin allowlist is hardcoded in source. Detailed above. Cost: small operational friction. Benefit: KV compromise cannot grant admin access.
- KV failure fails open for rate-limit and audit-log writes; fails closed for authentication.If KV is unreachable, the rate limiter logs a warning and permits the request (we'd rather serve traffic than deny everyone during an outage). Audit writes retry once and drop. Authentication paths (signature verification, ownership checks) do NOT fail open — they require KV to verify nonce consumption.
Secrets management
All secrets live in the deployment platform's encrypted environment variable store, never in source control. validateEnvironment() runs at module load and throws in production on any missing REQUIRED variable. Server-only secrets (JWT signing key, cron secret, VAPID private key, third-party API keys) never reach the client bundle — the Next.js convention enforces this via the NEXT_PUBLIC_ prefix.
JWT secret rotation: two parallel secrets, current and previous. Each issued token carries a kidheader with the current secret's fingerprint. During verification the kid selects which secret to use. Tokens issued before rotation continue to verify against previous for a grace window; after the grace window previous is unset and old tokens stop verifying — by design.
CRON_SECRET rotation:generate a new 32-byte hex string, update the Vercel env var, redeploy. There is no "previous" for cron secrets because cron schedules are atomic — schedule remains valid post-rotation.
Supply chain
Modern web-application compromises overwhelmingly arrive through the dependency tree, not through application code. FlareWatch's defences:
- Dependabot alerts enabled on the repo — vulnerable-dependency advisories surface as notifications when published.
- Grouped security updates — multiple alerts in the same package manager are batched into single PRs to reduce merge noise.
- npm audit runs in CI on every build; high/critical advisories fail the build.
- CycloneDX SBOM generated per build as an artifact.
- GitHub Actions third-party actions pinned to commit SHA, not floating tags.
- Fonts self-hosted via
next/font/google(Inter) andnext/font/local(Satoshi). Zero requests togoogleapis.comorfontshare.comat runtime. - S141 lint safeguard blocks future commits from introducing
eval(), undocumenteddangerouslySetInnerHTML, rawprocess.envin client components, or hook-bypass flags.
Audit history
- 2026-05-18 — Proof-citation audit. This document. Zero implementation findings. Two stale-claim corrections on the public docs page (CSP unsafe-* disclosure, COOP/CORP omission). Companion evidence doc shipped at /audits/security-architecture-2026-05-18.md.
- 2026-05-12 — Defense-in-depth follow-up. Permissions-Policy expanded from 10 → 22 features. X-Permitted-Cross-Domain-Policies: none added. Auth rate limits tightened to 5/min/IP. CSP Report-Only telemetry layer. S141 lint safeguard introduced. CI critical-vuln gate added.
- 2026-05-12 — Full-site post-upgrade audit. Zero critical, zero high, 5 medium, 6 low findings. Every code-side finding closed in-session. DNS / Cloudflare items closed within hours. CAA records, HSTS preload submission, edge-to-origin Full (Strict) encryption.
- April 2026 — SIWE security audit. 13 numbered findings. 8 shipped in first audit pass (4 critical: per-wallet ownership gates, cron-route fail-closed, admin URL backdoor removal). 3 high-severity shipped in second pass (rate limiting, JWT revocation, shared rate limiter migration). 5 low-severity closed as no-action with documented reasoning.
Monthly cadence — next audit scheduled per docs/audits/02-security.md in the source tree. All historical reports retained internally; summaries surface to this page and to the Settings → Security Activity card.
Disclosure policy
Security vulnerabilities should be reported per the policy at /.well-known/security.txt (RFC 9116). FlareWatch supports coordinated disclosure with safe-harbor language for good-faith researchers.
Email: hello@flarewatch.io. Once a fix has shipped, we are happy to publicly credit researchers who want credit.
Further reading
- Architecture overview — how FlareWatch is built end-to-end.
- Safeguards reference — the numbered safeguards (S1, S6, S46, S52, S64b, S125, S134, S141, …) referenced throughout this page.
- 2026-05-18 audit-evidence document — long-form companion with the publicly-observable verification checklist.