Authorization lives in the database
Postgres row-level security is the authorization boundary. Every table in the application schema has RLS enabled with explicit policies, there is no using (true) anywhere, and the anon role holds no table grants. The checks in the application are user-interface affordances: they decide what to render and what to grey out. What decides whether a row can be read or written is the database, evaluated per row on every query, including queries that skip our code and talk to the REST endpoint directly.
A board's audience is computed by a single function. A board either belongs to the whole organisation or to one team plus the organisation's owners and admins, and every board-scoped policy — nodes, edges, comments, node states and the realtime channel — resolves through that one function rather than repeating the rule.
Tenancy and provenance columns are immutable by grant, not by policy. UPDATE is granted per column, and no role holds it on org_id, board_id, a row identity, created_by or created_at. Inserts are column-scoped the same way, so a client cannot choose a row's primary key, backdate it, or attribute it to another person. This matters because a policy alone is not enough: a user who legitimately belongs to both sides of a move satisfies both the old-row and new-row halves of a policy, and only the missing grant stops the write.
Server Actions re-derive the caller from the session cookie on every request. An organisation or board id arriving from a browser is only ever a lookup key, never an authorization claim. Inputs at those boundaries are validated with schemas shared between the form and the action. Every SECURITY DEFINER function pins search_path = public, pg_temp, which is what keeps a definer-rights function from being turned into a privilege-escalation path.
Realtime is authorized too
Collaboration runs over a per-board Supabase Realtime channel. Database change events on that channel are filtered by the same row-level policies as any other read. Presence and broadcast — the live roster and cursor frames — are not covered by table policies at all, so the channel is opened private and authorized by explicit policies on realtime.messages that resolve the channel topic back to a board and require the same reader check.
Those policies are evaluated per frame, so somebody removed from a team stops receiving presence and cursors on the next frame rather than on their next page load. The private flag is a single word that would be easy to delete by accident, so the channel configuration is unit-tested on its own.
Invitations
An invitation is a 32-byte random token shown to the inviter once. Only its SHA-256 digest is stored, so the database never holds anything that can be replayed as a link. Tokens are single-use, expire after seven days, and are bound to the address they were sent to.
The binding is enforced by requiring a confirmed email address: acceptance refuses a caller whose address has not been confirmed. A leaked invitation link therefore cannot be redeemed by whoever happens to read it — they would have to prove they control the invited mailbox. Two tests stand behind this, one driving the real browser flow and one asserting the database function directly.
Secrets
The Supabase service-role key is reachable only from modules that begin with import 'server-only', and it is used on exactly two paths: the Stripe webhook and invitation acceptance. The same is true of the logging token and the Stripe secret.
This is checked rather than trusted. Continuous integration builds the application and then greps the emitted client bundles for each secret's variable name and, where the pipeline holds the value, for the literal value. A hit fails the build, so a server module accidentally pulled into a client component cannot reach a deploy.
Transport and browser headers
Every response carries HTTP Strict Transport Security with a two-year max-age, includeSubDomains and preload; frame-ancestors 'none' alongside X-Frame-Options: DENY; X-Content-Type-Options: nosniff; a Permissions-Policy that denies camera, microphone, geolocation and interest-cohort; and Referrer-Policy: strict-origin-when-cross-origin. A Content-Security-Policy restricts scripts, styles, images, connections and frames to a named list, with object-src 'none', base-uri 'self', form-action 'self' and upgrade-insecure-requests.
One honest exception: script-src includes 'unsafe-inline', because the framework's inline bootstrap and streaming payloads require it. That means the policy does not mitigate an injected inline script. Removing it needs a per-request nonce, which would also make every route dynamic, and that trade has not been made yet. The application renders all user content through the framework's escaping and the only raw-HTML injection in the codebase is a static theme constant with no interpolation.
What continuous integration proves on every change
- A policy test suite runs against a real Postgres instance and asserts that cross-tenant reads and writes are refused — by the database, as the attacking user — and that the legitimate writes the product makes still succeed.
- An end-to-end suite drives a real browser through sign-up, confirmation, invitation, the canvas and team scoping.
- Lint, a full production build and a strict type check must all be clean.
- The generated database types are regenerated and compared; a stale checked-in copy fails the build, so schema and application cannot drift apart silently.
- Migrations are forward-only and additive by policy, and a merge to the deployed branch applies them automatically and then verifies that no local migration is missing from the database.
Adversarial review
The codebase has been through internal adversarial review — someone attacking a running stack with real sessions for an owner, a member of two organisations, a viewer and an outsider, rather than reading the code and nodding. The reports, the reproductions and the resolution for each finding are published in the repository under docs/SECURITY-AUDIT.md. This is internal work, not a third-party penetration test, and we do not describe it as one.
Findings that were fixed
- High — a member could rewrite a board's organisation id and move the board with all of its nodes, edges and comments into an organisation they controlled; closed by granting
UPDATEper column so no role holds it on a tenancy column. - High — node coordinates had no database constraint, so any writer could store
NaNorInfinityand leave the board blank for everyone including the owner; closed with a range check on the columns. - Medium — realtime presence and broadcast were readable and forgeable by anyone who knew a board's id, with no login at all; closed by making the channel private and adding policies on the realtime message table.
- Medium — the rate-limit counter could be called by an unauthenticated caller with any key, which let an attacker lock a chosen account out of sign-in; execution is now granted to the service role only.
- Medium — the board limit was enforced on creation but not on restoring an archived board, so the cap could be walked around; the trigger now fires on that transition too.
Four low-severity findings were closed in the same work: forgeable authorship columns on node and edge inserts, a missing composite foreign key on comments, a redirect sanitiser that was not idempotent, and a webhook conflict that would have been retried forever. Each fix ships with a regression test, and the reports also record what was attacked and held.
Logging
Server-side events are structured and shipped to a log store. Email addresses, request and form bodies, query parameters, rate-limit keys, and board or node content are never logged. Database failures are recorded as an operation name and a SQLSTATE, never the arguments of the query.
The logger redacts secret-shaped keys and values as a backstop rather than as permission to pass them, and it is server-only, so its credentials cannot reach a browser. Client-side errors are reported through an endpoint instead of being sent anywhere directly.
What is not here
Stated plainly, because a reviewer will ask and because discovering one of these after a procurement call is worse than reading it now.
- No SOC 2 or ISO 27001 certification. No audit has been performed and no report exists.
- No single sign-on or SAML. Sign-in is email and password, or GitHub and Google where the deployment has configured them.
- No enforced multi-factor authentication. There is no second factor to enrol and no organisation-wide setting to require one.
- No customer-facing audit log or export. A small internal record of organisation and board creation exists; there is no interface to read or export it.
- No service-level agreement. Nothing here commits to an uptime figure, a support response time or a breach-notification window.
None of these are sold, priced or promised anywhere else on this site either. If any of them is a requirement for you, it is a requirement NodeKanban does not meet today.
Reporting a vulnerability
If you find something, tell us before you tell anyone else and give us a reasonable window to fix it. A useful report names the endpoint or table, the account or role you were acting as, and the steps to reproduce; a raw request or a short script is ideal. Please do not run automated scans against the production deployment, do not access or modify data that is not yours, and stop at the point where you have proved the issue rather than exploring further. There is no bug bounty — we cannot pay, and we would rather say so than imply otherwise. Send reports to hello@nodekanban.com.
Who else processes your data
- Supabase — the Postgres database, authentication, realtime, and the transactional mail the product depends on.
- Vercel — application hosting, and Web Analytics, which records page views without cookies.
- Axiom — server log storage, when a deployment configures it. Unset, logs stay on the server's own output.
- Resend — outbound product email, when a deployment configures it.
- Stripe — payment wiring that exists in the codebase but is dormant: nothing is sold, no checkout is reachable, and no card details are collected.
We do not assert a hosting region on this page. Where data is hosted, and the transfer mechanism relied on, belong in the privacy notice, which is where they are recorded.