Supabase setup guide
A minimal guide to connecting this app to Supabase: what you need, where the keys go, and copy-paste examples for writing data and calling the ingest webhook.
Prerequisites
- A Supabase project (free tier works).
- Bun 1.2+ or Node.js 22+.
- A running local dev server (
bun run dev→ http://localhost:8080).
Installation
- Install dependencies:
bun install
- Copy the environment template and fill in your Supabase keys:
cp .env.local.example .env.local
- Start the dev server:
bun run dev
Required environment variables
Add each value to your environment or secret store. Only variables prefixed with VITE_ ship to the browser.
VITE_SUPABASE_URL
Project URL from Supabase Project Settings → API. Safe in the browser.
- Where
- .env
VITE_SUPABASE_PUBLISHABLE_KEY
Anon/public key from the same page. Safe in the browser.
- Where
- .env
SUPABASE_URL
Same project URL, used server-side.
- Where
- Runtime secret / server env
SUPABASE_PUBLISHABLE_KEY
Same anon key, used server-side for public reads.
- Where
- Runtime secret / server env
SUPABASE_SERVICE_ROLE_KEY
From Project API keys → service_role. Bypasses RLS; never expose to the browser.
- Where
- Runtime secret only
Webhook checklist
Make sure these three values are set before sending a signed finding:
- SUPABASE_SERVICE_ROLE_KEY — Server-only key that lets the ingest endpoint write findings to the database. Never expose it to the browser.
- NSO_HMAC_SECRET — Tenant-specific signing secret from Settings → Webhooks. Used to compute the signature for every ingest request.
- NSO_WEBHOOK_HOST — The origin of this NSO app (e.g.,
https://nso.jackiepoole.com). The sample request is POSTed to{NSO_WEBHOOK_HOST}"/api/public/ingest/{NSO_TENANT_SLUG}".
.env.example file
Copy these placeholders into your local .env.local or secret store and replace each value with the real one.
# Supabase server-side credentials # Get these from your Supabase project dashboard → Project Settings → API SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs... # NSO webhook configuration # Find these in the NSO app under Settings → Webhooks NSO_HMAC_SECRET=your-tenant-hmac-secret NSO_WEBHOOK_HOST=https://nso.jackiepoole.com
A downloadable version is also available at /.env.example .
Security rule
Only VITE_* variables ship to the browser. The service role key stays on the server and is read inside server functions via process.env.SUPABASE_SERVICE_ROLE_KEY.
Where to paste API keys and env vars
Each value has exactly one destination. Public/browser-safe keys go in .env; server-only keys go in .env.local or your hosting secret store. Never paste the service role key where the browser can read it.
1. Get the keys from your Supabase project
- Open your Supabase project and go to Project Settings → API.
- Copy Project URL — this becomes
VITE_SUPABASE_URLandSUPABASE_URL. - Copy Project API keys → anon / public — this becomes
VITE_SUPABASE_PUBLISHABLE_KEYandSUPABASE_PUBLISHABLE_KEY. - Copy Project API keys → service_role — this becomes
SUPABASE_SERVICE_ROLE_KEY. Keep it server-only.
2. Paste them into the NSO project
| Variable | Paste in | Why |
|---|---|---|
| VITE_SUPABASE_URL | .env | Ships to the browser; safe to expose. |
| VITE_SUPABASE_PUBLISHABLE_KEY | .env | Public anon key; required by the browser client. |
| SUPABASE_URL | .env.local or secret store | Server-side duplicate; not shipped to the browser. |
| SUPABASE_PUBLISHABLE_KEY | .env.local or secret store | Server-side duplicate for public reads. |
| SUPABASE_SERVICE_ROLE_KEY | .env.local or secret store | Secret. Bypasses RLS; never commit or expose. |
3. Local development vs. deployed app
- Local dev: put public keys in
.envand secrets in.env.local..env.localis ignored by git. - Lovable Cloud / production: add server secrets in Cloud → Secrets. The publishable keys are already injected by the integration; only add custom or override values if needed.
- Other hosts: set each variable in the platform's secret/ environment UI. Match the names exactly — they are case-sensitive.
4. Quick paste template
Replace the placeholders with the values copied from Supabase:
# .env (public, committed example — replace with real values) VITE_SUPABASE_URL=https://your-project-ref.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... # .env.local (server-only, never committed) SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_PUBLISHABLE_KEY=sb_publishable_... SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
Validate your environment variables
Paste your values below to check that each required variable is present and formatted correctly before starting the dev server. Validation runs entirely in your browser — nothing is sent to the server.
Fill in all 8 fields to validate
Example: insert data from the browser
Create a table in the Supabase SQL editor, then read and write from a component.
a. Create the table
create table public.notes ( id uuid primary key default gen_random_uuid(), body text not null, created_at timestamptz not null default now() ); grant select, insert on public.notes to anon, authenticated; alter table public.notes enable row level security; create policy "anyone can read notes" on public.notes for select to anon, authenticated using (true); create policy "anyone can insert notes" on public.notes for insert to anon, authenticated with check (true);
b. Read and write from a component
import { supabase } from "@/integrations/supabase/client";
// Insert
const { data: inserted, error: insertError } = await supabase
.from("notes")
.insert({ body: "hello from supabase" })
.select()
.single();
// Read
const { data: notes, error: readError } = await supabase
.from("notes")
.select("id, body, created_at")
.order("created_at", { ascending: false })
.limit(10);
console.log({ inserted, insertError, notes, readError });A populated inserted object plus a non-empty notes array confirms the URL, key, grants, and RLS policies are wired correctly.
Example: call the ingest webhook
The app exposes a signed webhook at /api/public/ingest/<tenant-slug>. External scanners post findings there using a tenant-specific HMAC secret.
- In the app, go to Settings → Webhooks and copy the HMAC secret and tenant slug for your tenant.
- Send a signed POST:
export TENANT_SLUG=my-team
export NSO_SECRET=your-tenant-hmac-secret
export HOST=https://nso.jackiepoole.com
python3 - <<'PY'
import hmac, hashlib, json, os, urllib.request, datetime
payload = {
"findings": [
{
"scanner": "drift",
"asset": "example.com",
"title": "TLS certificate expires in 7 days",
"severity": "medium",
"cvss": 5.0,
"fingerprint": "drift:example.com:tls-expiry",
"description": "The certificate expires soon.",
"remediation": "Renew the certificate before expiry.",
"controls": ["soc2:CC6.1"]
}
]
}
body = json.dumps(payload)
sig = hmac.new(os.environ["NSO_SECRET"].encode(), body.encode(), hashlib.sha256).hexdigest()
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
req = urllib.request.Request(
f"{os.environ['HOST']}/api/public/ingest/{os.environ['TENANT_SLUG']}",
data=body.encode(),
headers={
"content-type": "application/json",
"x-nso-signature": sig,
"x-nso-timestamp": timestamp,
},
method="POST",
)
resp = urllib.request.urlopen(req)
print(resp.status, resp.read().decode())
PYExpected response:
{ "ok": true, "inserted": 1, "updated": 0, "total": 1 }The webhook upserts findings on (tenant_id, fingerprint). Re-sending the same fingerprint updates last_seen, severity, and CVSS instead of creating a duplicate.
Exact JSON body and HMAC signing
The tenant HMAC secret is used only on the sender side to sign the raw JSON body. It is never sent in the request. The server recomputes the signature with its stored copy of the secret to verify the payload.
{
"findings": [
{
"scanner": "guide-tester",
"asset": "example.com",
"title": "Sample finding from Supabase guide",
"severity": "low",
"cvss": 2.0,
"fingerprint": "guide-tester:example.com:1234567890",
"description": "Test finding generated from the setup guide.",
"remediation": "No action needed — delete after verification.",
"controls": ["soc2:CC6.1"]
}
]
}Sign the exact raw body with the tenant secret:
const crypto = require("crypto");
const NSO_HMAC_SECRET = "your-tenant-hmac-secret"; // from Settings → Webhooks
const body = JSON.stringify(payload); // exact JSON string above
const signature = crypto
.createHmac("sha256", NSO_HMAC_SECRET) // <-- secret used here
.update(body) // <-- over the raw JSON body
.digest("hex");
// Headers sent with the request
const headers = {
"content-type": "application/json",
"x-nso-signature": signature,
"x-nso-timestamp": new Date().toISOString(), // required, not part of signature
};The signature and body travel separately. The server validates the signature against the body bytes it receives, so pretty-printing or re-serializing the JSON after signing will break verification. The x-nso-timestamp header is required for replay protection but is not included in the HMAC calculation.
Try it: send a sample request
Prefilled from your validated env vars above. Signs the payload with HMAC-SHA256 in your browser and posts a single test finding. Nothing is sent until you click the button.
POST —
Verify incoming webhook requests
Every NSO ingest request includes an HMAC-SHA256 signature in the x-nso-signature header and a required x-nso-timestamp header for replay protection. On your receiving endpoint, first check the timestamp is present and within the allowed skew window, then recompute the signature with your NSO_HMAC_SECRET and reject requests that do not match.
- Read the
x-nso-timestampheader. If it is missing, return 401 Missing timestamp. - Parse the timestamp and confirm it is within 5 minutes of the server clock. If it is stale or too far in the future, return 401 Stale timestamp.
- Read the raw request body as bytes (do not parse or re-serialize it before verifying).
- Read the
x-nso-signatureheader. - Compute HMAC-SHA256 over the raw body using your
NSO_HMAC_SECRET. - Compare the computed signature to the header value with a timing-safe equality check.
- Only parse and process the JSON body after the signature matches. If it does not match, return 401 Unauthorized.
Python example
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
NSO_HMAC_SECRET = "your-tenant-hmac-secret" # from Settings → Webhooks
@app.route("/api/public/ingest/<tenant_slug>", methods=["POST"])
def ingest(tenant_slug: str):
raw_body = request.get_data()
signature = request.headers.get("x-nso-signature", "")
expected = hmac.new(
NSO_HMAC_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
payload = request.get_json()
# process findings ...
return {"ok": True}Node.js / Express example
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
const NSO_HMAC_SECRET = "your-tenant-hmac-secret"; // from Settings → Webhooks
app.post("/api/public/ingest/:tenantSlug", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-nso-signature"] ?? "";
const expected = createHmac("sha256", NSO_HMAC_SECRET)
.update(req.body)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body);
// process findings ...
res.json({ ok: true });
});What to check if verification fails
- Make sure you are signing the exact bytes NSO sent, not a pretty-printed or re-serialized version of the JSON.
- Confirm the secret matches the tenant slug shown in Settings → Webhooks.
- Verify the header name is lowercase
x-nso-signature— some frameworks normalize header names. - Use a constant-time comparison (e.g.,
hmac.compare_digestorcrypto.timingSafeEqual) to avoid timing attacks.
End-to-end handler: verify, then insert into Supabase
This example verifies the x-nso-signature header with your NSO_HMAC_SECRET and, only after the signature matches, inserts each finding into a public.findings table using the service role key. Reject first, insert second.
Table + RLS
create table public.findings ( id uuid primary key default gen_random_uuid(), tenant_slug text not null, scanner text not null, asset text not null, title text not null, severity text not null, cvss numeric, fingerprint text not null, raw jsonb, created_at timestamptz not null default now(), unique (tenant_slug, fingerprint) ); grant select, insert, update on public.findings to service_role; alter table public.findings enable row level security; -- No anon/authenticated policies: only the service role writes here.
Node.js / Express handler
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
import { createClient } from "@supabase/supabase-js";
const app = express();
const NSO_HMAC_SECRET = process.env.NSO_HMAC_SECRET!;
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // server-only, bypasses RLS
{ auth: { persistSession: false } },
);
app.post(
"/api/public/ingest/:tenantSlug",
express.raw({ type: "application/json" }),
async (req, res) => {
// 1. Verify signature over the raw bytes
const signature = String(req.headers["x-nso-signature"] ?? "");
const expected = createHmac("sha256", NSO_HMAC_SECRET)
.update(req.body)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).json({ error: "Invalid signature" });
}
// 2. Parse only after verification succeeds
let payload: { findings: Array<Record<string, unknown>> };
try {
payload = JSON.parse(req.body.toString("utf8"));
} catch {
return res.status(400).json({ error: "Invalid JSON" });
}
if (!Array.isArray(payload.findings) || payload.findings.length === 0) {
return res.status(400).json({ error: "No findings" });
}
// 3. Upsert into Supabase using the service role key
const rows = payload.findings.map((f) => ({
tenant_slug: req.params.tenantSlug,
scanner: String(f.scanner ?? ""),
asset: String(f.asset ?? ""),
title: String(f.title ?? ""),
severity: String(f.severity ?? "info"),
cvss: typeof f.cvss === "number" ? f.cvss : null,
fingerprint: String(
f.fingerprint ?? `${f.scanner}:${f.asset}:${f.title}`,
),
raw: f,
}));
const { error, count } = await supabase
.from("findings")
.upsert(rows, { onConflict: "tenant_slug,fingerprint", count: "exact" });
if (error) {
console.error("[ingest] insert failed", error);
return res.status(500).json({ error: "Insert failed" });
}
res.json({ ok: true, total: rows.length, inserted: count ?? rows.length, updated: 0 });
},
);
app.listen(8080);Key rules
- Verify the HMAC over the raw bytes before parsing JSON or touching the database.
- Use the service role key only on the server. Never ship it to the browser or a client-side bundle.
- Upsert on
(tenant_slug, fingerprint)so a retried delivery does not create duplicates.
Test the webhook end-to-end with curl
Run these commands from a shell that can reach your handler. Each step is copy-paste ready — fill in the four env vars in step 1 and the rest just work.
1. Export the values you need
export NSO_WEBHOOK_HOST="https://your-handler.example.com" export NSO_TENANT_SLUG="my-team" export NSO_HMAC_SECRET="your-tenant-hmac-secret" # Settings → Webhooks export SUPABASE_URL="https://xxxx.supabase.co" export SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOi..." # server-only
2. Build the payload and sign it
The signature is computed over the exact bytes you send. Save the body to a file so curl and openssl see the same input.
cat > /tmp/nso-payload.json <<'JSON'
{
"findings": [
{
"scanner": "curl-smoke",
"asset": "example.com",
"title": "curl end-to-end test finding",
"severity": "low",
"cvss": 2.0,
"fingerprint": "curl-smoke:example.com:e2e-1"
}
]
}
JSON
SIG=$(openssl dgst -sha256 -hmac "$NSO_HMAC_SECRET" /tmp/nso-payload.json | awk '{print $NF}')
TS=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
echo "x-nso-signature: $SIG"
echo "x-nso-timestamp: $TS"3. POST to the webhook
curl -sS -i -X POST \ "$NSO_WEBHOOK_HOST/api/public/ingest/$NSO_TENANT_SLUG" \ -H "content-type: application/json" \ -H "x-nso-signature: $SIG" \ -H "x-nso-timestamp: $TS" \ --data-binary @/tmp/nso-payload.json
Expect HTTP/1.1 200 OK with a JSON body like {"ok":true,"total":1,"inserted":1,"updated":0}. A 401 means the signature didn't match or the request was missing a required x-nso-timestamp header — re-check the secret, the timestamp skew, and that you signed the same bytes you posted.
4. Confirm the row landed in Supabase
Query the findings table through PostgREST using the service role key (bypasses RLS):
curl -sS \ "$SUPABASE_URL/rest/v1/findings?tenant_slug=eq.$NSO_TENANT_SLUG&fingerprint=eq.curl-smoke:example.com:e2e-1" \ -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"
You should get a JSON array with one row containing the finding you posted. An empty array [] means the handler returned 200 but didn't insert — check the handler logs for a Supabase error.
5. Re-send to confirm idempotency
curl -sS -X POST \ "$NSO_WEBHOOK_HOST/api/public/ingest/$NSO_TENANT_SLUG" \ -H "content-type: application/json" \ -H "x-nso-signature: $SIG" \ -H "x-nso-timestamp: $TS" \ --data-binary @/tmp/nso-payload.json
The response should still be {"ok":true,"total":1,"inserted":1,"updated":0}, and the Supabase query in step 4 should still return a single row — the upsert on (tenant_slug, fingerprint) deduplicates retries.
Required headers and HMAC signature format
Every ingest request must include two x-nso-* headers plus content-type: application/json. The signature is computed from the raw JSON body and the tenant HMAC secret — never send the secret itself. The timestamp header is required for replay protection but is not included in the signature.
| Header | Required value | Why it matters |
|---|---|---|
| content-type | application/json | Tells the server to parse the body as JSON. |
| x-nso-signature | hex(HMAC-SHA256(body, NSO_HMAC_SECRET)) | Proves the payload came from someone who knows the tenant secret. |
| x-nso-timestamp | ISO 8601 UTC timestamp (e.g. 2026-01-01T00:00:00.000Z) | Required for replay protection. Must be within 5 minutes of server time. Missing or stale timestamps return 401. The exact x-nso-signature:x-nso-timestamp pair is also stored as a nonce, so replaying the same signed request returns 409. |
HMAC signature format
signature = HMAC-SHA256(
key = NSO_HMAC_SECRET, // from Settings → Webhooks
message = raw JSON request body // exact bytes sent in the POST body
).digest("hex")The signature is a lowercase hexadecimal string. It is computed over the exact bytes placed in the request body, so pretty-printing, adding whitespace, or re-serializing the JSON after signing will cause a 401 Invalid signature response.
x-nso-timestamp format
The timestamp must be a valid ISO 8601 string in UTC. The server parses it with new Date(x-nso-timestamp) and rejects anything that does not parse to a real time.
# Valid — full ISO 8601 UTC 2026-01-01T00:00:00.000Z # Valid — no milliseconds 2026-01-01T00:00:00Z # Valid — with timezone offset (still parsed to UTC) 2026-01-01T00:00:00+00:00 # Invalid — missing T or Z 2026-01-01 00:00:00 # Invalid — empty or non-ISO string "now" "1704067200000"
The easiest way to produce a valid value is new Date().toISOString() in JavaScript or datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") in Python.
Copy-paste cURL commands
The block below sets the required variables, builds the JSON body, computes the HMAC-SHA256 signature with openssl, sets both x-nso-* headers, and POSTs the request. It uses only curl and openssl.
# 1. Set the required variables
export NSO_WEBHOOK_HOST="https://nso.jackiepoole.com"
export NSO_TENANT_SLUG="my-team"
export NSO_HMAC_SECRET="your-tenant-hmac-secret"
# 2. Build the exact JSON body you will send
cat > /tmp/nso-payload.json <<'JSON'
{
"findings": [
{
"scanner": "curl-guide",
"asset": "example.com",
"title": "Copy-paste cURL test finding",
"severity": "low",
"cvss": 2.0,
"fingerprint": "curl-guide:example.com:demo-1",
"description": "Demo finding from the cURL guide.",
"remediation": "No action needed.",
"controls": ["soc2:CC6.1"]
}
]
}
JSON
# 3. Compute the HMAC-SHA256 signature over the exact bytes
SIG=$(openssl dgst -sha256 -hmac "$NSO_HMAC_SECRET" /tmp/nso-payload.json | awk '{print $NF}')
# 4. Build the required x-nso-timestamp header
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# 5. Send the signed request
curl -sS -i -X POST "$NSO_WEBHOOK_HOST/api/public/ingest/$NSO_TENANT_SLUG" -H "content-type: application/json" -H "x-nso-signature: $SIG" -H "x-nso-timestamp: $TS" --data-binary @/tmp/nso-payload.jsonExpected response: HTTP/1.1 200 OK and a JSON body like {"ok":true,"total":1,"inserted":1,"updated":0}. A 401 means the signature or timestamp was rejected; a 409 means this exact signature+timestamp pair was already used.
TypeScript HMAC signing example
The example below uses Node.js crypto to build the exactx-nso-signature and x-nso-timestamp headers, then sends the signed request with fetch.
import { createHmac } from "node:crypto";
const NSO_WEBHOOK_HOST = "https://nso.jackiepoole.com";
const NSO_TENANT_SLUG = "my-team";
const NSO_HMAC_SECRET = "your-tenant-hmac-secret"; // from Settings → Webhooks
const body = JSON.stringify({
findings: [
{
scanner: "typescript-guide",
asset: "example.com",
title: "TypeScript HMAC test finding",
severity: "low",
cvss: 2.0,
fingerprint: "typescript-guide:example.com:demo-1",
description: "Demo finding from the TypeScript guide.",
remediation: "No action needed.",
controls: ["soc2:CC6.1"],
},
],
});
const timestamp = new Date().toISOString();
const signature = createHmac("sha256", NSO_HMAC_SECRET)
.update(body)
.digest("hex");
const response = await fetch(
`${NSO_WEBHOOK_HOST}/api/public/ingest/${NSO_TENANT_SLUG}`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-nso-signature": signature,
"x-nso-timestamp": timestamp,
},
body,
},
);
const result = await response.json();
console.log(response.status, result);The signature is computed over the exact JSON string in body, so do not re-serialize or pretty-print the payload after signing. The timestamp must be a valid ISO 8601 UTC string.
Postman header setup (manual request)
If you are building the request by hand instead of using the collection, set the headers like this under the Headers tab:
content-type: application/json
x-nso-signature: {{SIGNATURE}}
x-nso-timestamp: {{TIMESTAMP}}Then use a pre-request script (or the collection provided above) to populate SIGNATURE and TIMESTAMP:
const body = pm.variables.get('SIGNED_BODY'); // the exact JSON string in the body
const secret = pm.variables.get('NSO_HMAC_SECRET');
const sig = CryptoJS.HmacSHA256(body, secret).toString(CryptoJS.enc.Hex);
pm.variables.set('SIGNATURE', sig);
pm.variables.set('TIMESTAMP', new Date().toISOString());Request URL
POST {NSO_WEBHOOK_HOST}/api/public/ingest/{NSO_TENANT_SLUG}Replace {NSO_WEBHOOK_HOST} with the origin of this NSO app and {NSO_TENANT_SLUG} with the slug shown in Settings → Webhooks.
Common Postman mistakes
- Pretty-printing the body after signing. Postman’s body editor can reformat JSON. Sign the exact string that is sent on the wire — the collection script stores it in
SIGNED_BODYfor this reason. - Using a different secret. Each tenant has its own HMAC secret. A 401 usually means the secret in the environment variable does not match the tenant slug in the URL.
- Forgetting the content-type header. Without it the server may not parse the body as JSON and the signature check will fail.
- Missing or stale x-nso-timestamp. The header is required. Requests without it return
401 Missing timestamp; timestamps outside a 5-minute skew return401 Stale timestamp. - Omitting x-nso-signature. The header is required. Requests without it return
401 Invalid signature. - Sending a payload that does not match the findings schema. Even with a valid HMAC signature and timestamp, a body missing required fields (for example,
title) or using an invalidseverityvalue returns400 Invalid payloadand is not written to the database. - Sending the secret in a header or body. The secret is only used to compute the signature; the request itself carries only the signature.
Postman collection walkthrough
Prefer a GUI over curl? Import the collection below to exercise the same sign → POST → verify → re-send flow from Postman. The pre-request script computes the HMAC-SHA256 signature over the exact JSON body it sends and adds the required x-nso-timestamp header, so you never paste either value by hand.
NSO_HMAC_SECRET from your environment, resolves any {{...}} references in the raw JSON body, computes HMAC-SHA256(body, secret), and upserts the exact x-nso-signature and x-nso-timestamp headers on every POST — GETs and requests without a raw body are skipped. To reuse it in your own collection, open Edit collection → Pre-request Scripts and paste:const method = (pm.request.method || '').toUpperCase();
const rawBody = pm.request.body && pm.request.body.raw;
if (method !== 'POST' || !rawBody) return;
const secret = pm.variables.get('NSO_HMAC_SECRET')
|| pm.environment.get('NSO_HMAC_SECRET');
if (!secret) { console.warn('NSO_HMAC_SECRET not set'); return; }
const resolved = pm.variables.replaceIn(rawBody);
const signature = CryptoJS.HmacSHA256(resolved, secret)
.toString(CryptoJS.enc.Hex);
pm.request.headers.upsert({ key: 'content-type', value: 'application/json' });
pm.request.headers.upsert({ key: 'x-nso-signature', value: signature });
pm.request.headers.upsert({ key: 'x-nso-timestamp', value: new Date().toISOString() });1. Import the environment & fill in values
Import nso-webhook.postman_environment.json alongside the collection, then select NSO Webhook — Environment from the environment dropdown (top-right in Postman). Open Manage Environments and fill in the Current value column for each entry — the collection and pre-request script read directly from these, so one click runs the whole flow:
NSO_WEBHOOK_HOST— e.g.https://nso.jackiepoole.comNSO_TENANT_SLUG— your tenant slug from Settings → WebhooksNSO_HMAC_SECRET— the tenant HMAC secret; used only inside the pre-request script, never sent as a headerSUPABASE_PROJECT_REF— your project ref (the subdomain of your Supabase URL)SUPABASE_URL,SUPABASE_TABLE, andSUPABASE_SERVICE_ROLE_KEY— used only by request 2 to verify the row exists. Mark the service role key as a secret variable so it stays masked.
2. Run “1. POST signed ingest”
The pre-request script builds a small findings payload, computes CryptoJS.HmacSHA256(body, NSO_HMAC_SECRET), and stores the body, signature, and timestamp as collection variables so the request headers stay consistent with what is signed:
const body = JSON.stringify(payload);
const sig = CryptoJS.HmacSHA256(body, pm.variables.get('NSO_HMAC_SECRET'))
.toString(CryptoJS.enc.Hex);
pm.variables.set('SIGNED_BODY', body);
pm.variables.set('SIGNATURE', sig);
pm.variables.set('TIMESTAMP', new Date().toISOString());Expected response: {"ok":true,"total":1,"inserted":1,"updated":0}. A 401 Invalid signature almost always means the wrong NSO_HMAC_SECRET — the body is JSON-stringified inside the script, so nothing else can drift. A 401 Missing timestamp or Stale timestamp means the required x-nso-timestamp header was not added by the pre-request script.
3. Run “2. Verify row in Supabase”
Queries {{SUPABASE_URL}}/rest/v1/findings?fingerprint=eq.{{FINGERPRINT}} with the service role key. The built-in test asserts the response contains exactly one row whose fingerprint matches the one the webhook just upserted.
4. Run “3. Re-send to confirm idempotency”
Reuses SIGNED_BODY from request 1 and re-signs it. The webhook should return 200 again, and request 2 should still return a single row — the upsert on (tenant_slug, fingerprint) deduplicates retries.
5. Run “4. POST with bad signature (expect 401)”
Signs a fresh payload (using BAD_FINGERPRINT) with an intentionally wrong secret and sends it to the same endpoint. The webhook must reject the request before touching Supabase — the built-in test asserts the response status is 401 (or 403) and that the body does not contain ok: true. A 200 here means signature verification is broken — stop and audit the handler before shipping.
6. Run “5. Verify NO row was inserted for bad signature”
Queries {{SUPABASE_URL}}/rest/v1/findings?fingerprint=eq.{{BAD_FINGERPRINT}} with the service role key and asserts the response array is empty. Together, steps 5 and 6 prove the handler both refuses the request and never persists the payload when the HMAC signature is invalid.
7. Run “6. Happy-path: signed POST + verify insert”
One-click positive test. The pre-request script signs a fresh payload with NSO_HMAC_SECRET using a dedicated HAPPY_FINGERPRINT. The test script asserts the webhook returns 200 with ok: true and inserted >= 1, then uses pm.sendRequest to query findings in Supabase and confirm exactly one row exists with the matching fingerprint and title — POST and verify in a single click.
8. Run “7. Send twice and assert no duplicate rows”
This request posts the same signed payload twice from a single test and proves the upsert is idempotent. The first POST uses a dedicated IDEMPOTENT_FINGERPRINT and asserts 200, ok: true, and inserted >= 1. The test script then chains a second identical POST via pm.sendRequest, asserts the second response is also 200 with an idempotent inserted value, and finally queries Supabase to confirm findings still contains exactly one row for that fingerprint. Two sends, one row — no duplicates.
9. Run “8. Reject wrong signature + assert no insert (single request)”
A consolidated negative test. The pre-request script signs a payload with an intentionally wrong HMAC secret using BAD_SIG_ONLY_FINGERPRINT and sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite the bad signature. The test asserts the webhook returns 401 (or 403) and that the body is not ok: true. It then chains a Supabase lookup to confirm zero rows exist for the rejected fingerprint — a single click proves both the auth failure and the no-insert guarantee.
10. Run “9. Reject missing signature header + assert no insert”
Tests the case where the required x-nso-signature header is completely absent. The request body uses a fresh MISSING_SIG_FINGERPRINT and the pre-request script sets SKIP_AUTO_SIGN so the collection-level signer does not add the header. The request only sends content-type: application/json. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify no row was inserted for the missing-signature fingerprint. This confirms the handler rejects unsigned requests before any database write.
14. Run “14. Reject malformed HMAC signature + assert no insert”
Sends a valid JSON body with a clearly malformed x-nso-signature header (not a valid hex HMAC). The pre-request script sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite the bad signature. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for INVALID_HMAC_FINGERPRINT. This confirms malformed signatures are rejected before any database write.
15. Run “15. Omit x-nso-signature header + assert no insert”
Explicitly tests a request that omits the x-nso-signature header entirely. The pre-request script builds a valid JSON body using OMIT_SIG_FINGERPRINT and sets SKIP_AUTO_SIGN so the collection-level auto-signer does not add the header. Only content-type: application/json is sent. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for OMIT_SIG_FINGERPRINT. This confirms unsigned requests are rejected before any database write.
16. Run “16. Reject payload signed with different secret + assert no insert”
Tests the case where the payload is signed with a valid HMAC but using a different secret than the one configured for the tenant. The pre-request script builds a valid JSON body using DIFFERENT_SECRET_FINGERPRINT and signs it with a-completely-different-secret-that-is-not-the-tenant-secret, then sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite it. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for DIFFERENT_SECRET_FINGERPRINT. This confirms that only the tenant’s actual HMAC secret can produce an accepted signature.
17. Run “17. Reject payload tampered after signing + assert no insert”
Tests body integrity: the pre-request script signs the original JSON with the correct NSO_HMAC_SECRET, then mutates severity and title after computing the signature and sends the mutated body with the original (now stale) signature. It sets SKIP_AUTO_SIGN so the collection-level auto-signer does not re-sign the tampered body. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for TAMPERED_FINGERPRINT. This confirms the HMAC covers the exact bytes of the request body — any post-signature edit invalidates the signature and blocks the insert.
18. Run “18. Reject replayed (stale-timestamp) signed payload + assert no insert”
Simulates an attacker replaying a previously captured, validly signed request. The pre-request script builds a valid body using REPLAY_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and sends it with an x-nso-timestamp that is 10 minutes in the past — beyond the server's 5-minute skew window. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for REPLAY_FINGERPRINT. This confirms captured requests cannot be replayed after the skew window closes.
19. Run “19. Correctly signed payload returns success and inserts a row”
The final happy-path check. The pre-request script builds a valid body using SUCCESS_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and sends it without any tampering. The test asserts the webhook returns 200 with ok: true and inserted >= 1, then queries Supabase to verify exactly one row exists for SUCCESS_FINGERPRINT with the expected title and severity. This confirms the full pipeline — signature verification, payload validation, and database upsert — works end-to-end.
20. Run “20. Omit x-nso-signature header + assert auth error and no insert”
Tests that a request with no signature header at all is rejected before any database write. The pre-request script builds a valid JSON body using NO_SIG_FINGERPRINT and sets SKIP_AUTO_SIGN so the collection-level signer does not add the x-nso-signature header. Only content-type: application/json is sent. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for NO_SIG_FINGERPRINT. This confirms unsigned requests cannot reach the database.
21. Run “21. Reject incorrectly formatted signature + assert no insert”
Sends a valid JSON body with an x-nso-signature header that is incorrectly formatted — it contains non-hex characters and is not a valid HMAC-SHA256 hex digest. The pre-request script builds a fresh body using BAD_FORMAT_FINGERPRINT and sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite the malformed value. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for BAD_FORMAT_FINGERPRINT. This confirms only properly formatted hex HMAC signatures are accepted.
22. Run “22. Reject payload signed with wrong algorithm + assert no insert”
Tests algorithm enforcement: the pre-request script builds a valid JSON body using WRONG_ALGO_FINGERPRINT, then signs it with CryptoJS.HmacSHA1 using the real NSO_HMAC_SECRET. It sets SKIP_AUTO_SIGN so the collection-level auto-signer (which uses HMAC-SHA256) does not overwrite the signature, and sends the resulting digest in x-nso-signature. The test asserts the webhook returns 401 (or 403) and is not ok: true, then queries Supabase to verify zero rows exist for WRONG_ALGO_FINGERPRINT. This confirms the server only accepts HMAC-SHA256 signatures and rejects other hashing algorithms before any database write.
23. Run "23. Reject payload signed with expired timestamp + assert no insert"
Tests replay/timestamp skew protection: the pre-request script builds a valid JSON body using EXPIRED_TS_FINGERPRINT, signs it correctly with HMAC-SHA256 and the real NSO_HMAC_SECRET, but sets x-nso-timestamp to a value 10 minutes in the past — beyond the server's 5-minute skew window. It sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite the timestamp header. The test asserts the webhook returns 401 ( 403) and is not ok: true, then queries Supabase to verify zero rows exist for EXPIRED_TS_FINGERPRINT. This confirms the server rejects validly signed but stale-timestamp requests before any database write.
24. Run "24. Reject payload signed with future timestamp + assert no insert"
Tests that the skew window is enforced in both directions. The pre-request script builds a valid JSON body using FUTURE_TS_FINGERPRINT, signs it correctly with HMAC-SHA256 and the real NSO_HMAC_SECRET, but sets x-nso-timestamp to a value 10 minutes in the future — beyond the server's 5-minute skew window. It sets SKIP_AUTO_SIGN so the collection-level auto-signer does not overwrite the timestamp header. The test asserts the webhook returns 401 ( 403) and is not ok: true, then queries Supabase to verify zero rows exist for FUTURE_TS_FINGERPRINT. This confirms the server rejects validly signed but future-timestamp requests before any database write.
25. Run "25. Reject payload with missing x-nso-timestamp header + assert no insert"
Tests that x-nso-timestamp is a required header. The pre-request script builds a valid JSON body using OMIT_TS_FINGERPRINT, signs it correctly with HMAC-SHA256 and the real NSO_HMAC_SECRET, but does not send an x-nso-timestamp header. It sets SKIP_AUTO_SIGN so the collection-level auto-signer does not add a timestamp. The test asserts the webhook returns 401 ( 403) and is not ok: true, then queries Supabase to verify zero rows exist for OMIT_TS_FINGERPRINT. This confirms the server rejects any signed request that omits the timestamp before any database write.
38. Run “38. Replay identical signature and timestamp returns 409 Replay detected and only one insert”
Tests nonce-based replay protection. The pre-request script builds a valid JSON body using REPLAY_NONCE_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and stores the exact signature and timestamp in REPLAY_NONCE_SIG and REPLAY_NONCE_TS_HEADER. It sets SKIP_AUTO_SIGN so the collection-level auto-signer does not regenerate the headers. The test asserts the first request returns 200 with ok: true, then sends the exact same signature and timestamp again via pm.sendRequest. The second request asserts 409 Replay detected and verifies exactly one row exists for REPLAY_NONCE_FINGERPRINT. This confirms the server stores a nonce for every valid ingest and rejects byte-for-byte replays even when the timestamp is still inside the skew window.
39. Run “39. Omit x-nso-signature header returns 401 Invalid signature and no insert”
Tests the exact error documented for a missing signature. The pre-request script builds a valid JSON body using OMIT_SIG_EXACT_FINGERPRINT, sets SKIP_AUTO_SIGN so the collection-level signer does not add x-nso-signature, and sends only content-type: application/json and x-nso-timestamp. The test asserts the webhook returns 401 Invalid signature and is not ok: true, then queries Supabase to verify zero rows exist for OMIT_SIG_EXACT_FINGERPRINT. This confirms unsigned requests are rejected with the documented error before any database write.
40. Run “40. Omit x-nso-timestamp header returns 401 Missing timestamp and no insert”
Tests the exact error documented for a missing timestamp. The pre-request script builds a valid JSON body using OMIT_TS_EXACT_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and sets SKIP_AUTO_SIGN so the collection-level signer does not add x-nso-timestamp. Only content-type: application/json and x-nso-signature are sent. The test asserts the webhook returns 401 Missing timestamp and is not ok: true, then queries Supabase to verify zero rows exist for OMIT_TS_EXACT_FINGERPRINT. This confirms requests without the required timestamp header are rejected with the documented error before any database write.
41. Run “41. Wrong signature with valid body and timestamp returns 401 Invalid signature and no insert”
Tests the exact error for an invalid signature when the body and timestamp are otherwise correct. The pre-request script builds a valid JSON body using WRONG_SIG_VALID_BODY_TS_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and keeps x-nso-timestamp current. It then intentionally corrupts the signature (by reversing the hex digest) and sets SKIP_AUTO_SIGN so the collection-level signer does not overwrite it. The request sends the correct body and a valid timestamp, but the wrong x-nso-signature. The test asserts the webhook returns 401 Invalid signature and is not ok: true, then queries Supabase to verify zero rows exist for WRONG_SIG_VALID_BODY_TS_FINGERPRINT. This confirms only a signature that matches the exact request body is accepted.
42. Run “42. Correct signature and timestamp with invalid payload body returns 400 Invalid payload and no insert”
Tests schema validation after a successful signature check. The pre-request script builds an invalid JSON body using VALID_SIG_TS_MODIFIED_BODY_FINGERPRINT by intentionally omitting the required title field, then signs that exact body with the real NSO_HMAC_SECRET and uses a current x-nso-timestamp. It sets SKIP_AUTO_SIGN so the collection-level signer does not overwrite the headers. The signature and timestamp are correct for the body on the wire, but the body does not match the findings schema. The test asserts the webhook returns 400 Invalid payload and is not ok: true, then queries Supabase to verify zero rows exist for VALID_SIG_TS_MODIFIED_BODY_FINGERPRINT. This confirms payload validation runs after authentication and rejects malformed findings before any database write.
43. Run “43. Correct body with valid signature and timestamp returns success and inserts a row”
The canonical happy-path end-to-end test. The pre-request script builds a fully valid JSON body using VALID_FULL_SUCCESS_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and sends it with a current x-nso-timestamp. It sets SKIP_AUTO_SIGN so the collection-level signer does not overwrite the headers. The test asserts the webhook returns 200 with the exact success body { ok: true, inserted: 1, updated: 0, total: 1 }, then queries Supabase to verify exactly one row exists for VALID_FULL_SUCCESS_FINGERPRINT with the expected title and severity. This confirms the full pipeline — signature verification, timestamp validation, replay protection, schema validation, and database upsert — succeeds and persists the finding.
44. Run “44. Valid body with correct signature but expired timestamp returns 401 Stale timestamp and no insert”
Tests the exact error documented for an expired timestamp. The pre-request script builds a fully valid JSON body using EXPIRED_TS_EXACT_FINGERPRINT, signs it with the real NSO_HMAC_SECRET, and sets x-nso-timestamp to a value 10 minutes in the past — beyond the server's 5-minute skew window. It sets SKIP_AUTO_SIGN so the collection-level signer does not overwrite the timestamp. The signature is correct for the body, but the timestamp is stale. The test asserts the webhook returns 401 Stale timestamp and is not ok: true, then queries Supabase to verify zero rows exist for EXPIRED_TS_EXACT_FINGERPRINT. This confirms timestamp skew protection rejects validly signed requests with expired timestamps before any database write.
Keep the secret in the variable, not the header
NSO_HMAC_SECRET at run time and only sends the derived signature over the wire. Never add the raw secret as a header, and store the collection variable at the environment level so it does not travel with exported collections.Security notes
Private schema for role checks
The helper function that checks whether a user has a platform role was moved from the public schema into a private schema. Supabase exposes every function in the public schema as a PostgREST RPC endpoint, which means an authenticated attacker could have discovered and called it directly.
By moving the function to a private schema, that RPC exposure is removed. The app still uses it inside Row Level Security policies and server functions, where it runs with the necessary privileges and a locked search path. If you add your own helper functions that should never be callable from the browser, keep them out of the public schema.
Next steps
- Read the full Supabase auth/RLS docs: Row Level Security
- See the Supakit integration guide for a broader Supabase walkthrough.