FlareWatch

Security Features

FlareWatch's security posture, written for grant reviewers, security researchers, and contributors evaluating whether the application is safe to use. Every claim below is followed by the load-bearing code that implements it.

Last updated May 18, 2026

Defense by inspection

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.

The SIWE statement is pinned to one canonical string
Every SIWE message accepted by the server must contain the same statement constant. Messages whose statement differs are rejected. This blocks the "deniability inversion" phishing class where an attacker tricks a user into signing a misleading statement on a different site.
Implementation: 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);
}
Verify yourself: Read the FLAREWATCH_SIWE_STATEMENTconstant — it's a literal string, not derived from request input. Trace the equality check at the top of verifyMessage().
Why it matters: Bound at the protocol level — the statement is part of the bytes the wallet signs, so a signature valid on a different statement is mathematically invalid on ours. Closes the deniability gap that has been exploited against other web3 sign-in flows.
Mandatory expiration + chain ID + domain pinning
SIWE messages must carry an 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.
Implementation: 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 verification
Verify yourself: Confirm SIWE_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.
Why it matters: Three independent defences against signature reuse: a signature obtained on a different site (domain mismatch), a different chain (chain ID mismatch), or in the past (expiration) cannot be replayed against us.
Nonces are atomically consumed via GETDEL
Each SIWE attempt requires a fresh server-issued nonce, stored in KV with a 5-minute TTL. The nonce is consumed in a single atomic operation — there is no time-of-check vs time-of-use window. Two simultaneous attempts with the same nonce cannot both succeed.
Implementation: 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
}
Verify yourself: Read 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().
Why it matters: Single-use nonces prevent replay of captured signatures. The atomicity matters because two concurrent verifications racing on the same nonce would otherwise both pass, letting an attacker re-use a stolen signature within the 5-minute window.
Signature verification happens before nonce consumption
The signature is verified before the nonce is consumed. If verification fails, the nonce stays in KV and can be consumed by a later legitimate attempt. If verification succeeds, the nonce is burned and cannot be reused.
Implementation: 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);
}
Verify yourself: Trace the call order in verifyMessage(). The signature-verification call sits ahead of the consumeNonce() call.
Why it matters: Prevents the "victim-nonce-burn" DoS: without this ordering, an attacker could invalidate a pending legitimate user's nonce by spamming garbage signatures. We've seen this pattern weaponised on other SIWE deployments.

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.

Cookies use __Host- prefix, HttpOnly, Secure, and sameSite-restricted
Auth cookies use the __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).
Implementation: 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)
Verify yourself: Open browser DevTools → Application → Cookies on flarewatch.io. Confirm the auth cookie has HttpOnly ✓, Secure ✓, SameSite=Lax, and the __Host- prefix.
Why it matters: Four layered defences: 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.
Server-side revocation: signing out invalidates the cookie immediately
Signing out writes a revocation record server-side. Every subsequent authenticated request reads this record before honoring the JWT. A captured cookie stops working at the moment the user signs out — not at the 24-hour token expiry.
Implementation: 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 rejected
Verify yourself: Sign in. Capture the cookie value with DevTools. Sign out from a separate tab. Replay the captured cookie with curl — you'll get a 401, not the previously-authorised response.
Why it matters: Closes the "sign out doesn't actually sign out" gap that affected nearly every web3 dashboard until ~2024. A stolen cookie is useless the moment the user notices and clicks sign out.
Quick-unlock PIN: PBKDF2-310k, 5-attempt lockout, timing-safe compare
Optional PIN unlock for returning visitors. Hash uses PBKDF2-SHA256 with 310,000 iterations (matching OWASP 2023 guidance). Five failed attempts lock the PIN for 15 minutes. Comparison is timing-safe via crypto.timingSafeEqual.
Implementation: 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;
}
Verify yourself: Try 6 wrong PINs in a row. After the 5th wrong attempt, the 6th returns lockout. Wait 15 minutes; the lockout clears. The PIN hash in KV is binary and salted — not the PIN plaintext.
Why it matters: 310k PBKDF2 iterations make a 6-digit PIN brute force take more than a typical attacker session even at machine speed. The 5-attempt lockout makes online brute-force impractical regardless of PIN length. Timing-safe comparison closes the timing side-channel on hash equality.

Per-wallet ownership gating

Every per-wallet API route verifies the session owns the target address
All routes that read or write per-wallet KV state take the target wallet as a parameter and grant access on exactly two conditions (Safeguard 135, since 2026-06-09): the authenticated session is the target wallet (case-insensitive), or the target is an attestedwatched wallet on the session's own record. Attestation itself requires a signature from the target wallet, so the second path only ever reaches wallets whose ownership the session already proved — mere "observer" watches do not qualify, and the attestation read fails closed (403) on any KV error. There is no admin override path for user data: even an operator cannot read another user's wallet record without that user's session or that wallet's signed attestation.
Implementation: 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' };
}
Verify yourself: Sign in as wallet A. Make a request to /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.
Why it matters: This is the single most important gate in the application. If it fails open anywhere, one user could read or write another user's data. The attestation branch is sound because it never widens who can prove ownership: an attested wallet got that status by signing a challenge with its own key, so granting the anchor session access to it is equivalent to the ownership proof the direct path requires (and Safeguard 135's no-escalation invariant bounds what an attested "door" can change). The case-insensitive comparison is mandated by Safeguard 52 (EIP-55 checksum addresses can be presented in mixed case but are semantically equal lowercased).

Admin surface

Admin allowlist is hardcoded in source code, not KV
All /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.
Implementation: 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 } };
}
Verify yourself: Read constants/wallets.ts — the allowlist values are public. Try hitting any /api/admin/* route from a non-allowlisted wallet. You get 403.
Why it matters: A KV compromise cannot grant admin access — the allowlist is not in KV. The operational tradeoff is small (rare add-admin events) for a meaningful security property (KV attackers can't promote themselves).

Cron-route authentication

Every cron route verifies CRON_SECRET via timing-safe comparison
Every scheduled job at /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().
Implementation: 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 });
}
Verify yourself: Run this shell line against the codebase: 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).
Why it matters: Cron routes have privileged effects (refreshing KV caches, sweeping mirror state). Without this gate, anyone could trigger them at will, abusing our RPC budget and our scheduling invariants. The timing-safe comparison closes a subtle side-channel: naive equality leaks the secret length and the position of the first byte mismatch.

HTTP security headers

The following headers ship on every response:

  • Strict-Transport-Securitymax-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.

The CSP script-src includes 'unsafe-inline' and 'unsafe-eval'
We do not have a strict CSP. The 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.
Implementation: 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
  ...
Verify yourself: curl -sI https://flarewatch.io | grep -i 'content-security-policy' and read the script-src directive. Both 'unsafe-inline' and 'unsafe-eval' are present.
Why it matters: What this means in plain English: if an attacker can inject HTML into a server-rendered page (via a dependency vulnerability, an edge-function misconfiguration, or a stored-XSS bug in the codebase), inline <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

IP truncated to /16, UA capped at 80 chars, 30-day retention
Sign-in and sign-out events are logged per wallet with a truncated source IP and a capped user-agent. The truncation balances incident-response visibility with personal privacy. Retention is 30 days; older events are dropped via KV TTL.
Implementation: 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,
  };
}
Verify yourself: Sign in. Open Settings → Security Activity. The IP shown will be 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.
Why it matters: A user with significant FLR holdings should be able to spot "this sign-in came from a different network than mine" (the /16 prefix is enough). Granular IP storage would enable home-address inference if the audit log were ever exposed. The 30-day TTL limits historical exposure without losing recent-incident visibility.

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) and next/font/local (Satoshi). Zero requests to googleapis.com or fontshare.com at runtime.
  • S141 lint safeguard blocks future commits from introducing eval(), undocumented dangerouslySetInnerHTML, raw process.env in 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

FlareWatch FTSO delegation addresses. Flare (chainId 14): 0x973B899Fe1422efDdeBE1d54E9A6487a70966aC5. Songbird (chainId 19): 0xaf4eF2A0Ecf8d914Db6b721D3eb79C46CA612796.