name: pinpoint-security description: Security patterns, CSP nonces, input validation, auth checks, Supabase SSR patterns. Use when implementing authentication, forms, security features, or when user mentions security/validation/auth.
PinPoint Security Guide
When to Use This Skill
Use this skill when:
- Implementing authentication or authorization checks.
- Creating forms, validating user inputs, or handling FormData.
- Setting up or modifying security headers (CSP, HSTS, X-Frame-Options, etc.).
- Working with Supabase SSR authentication (Server Components, Server Actions, API routes, or Middleware).
- Implementing or modifying OAuth login/linking flows (e.g., Discord OAuth).
- Performing or verifying permission checks in pages, layouts, server actions, or client components.
- The user mentions: "security", "auth", "validation", "XSS", "CSRF", "input", "forms", "permissions", "roles", "CSP", "Discord".
Quick Reference
Critical Security Rules
- Permissions Go Through the Matrix (CORE-ARCH-008): All permission checks that gate a request or enforce authorization MUST go through
checkPermission()(server-side) orusePermission()(client-side) from~/lib/permissions/helpersandhooks. Hardcoded role checks (e.g.,role === "admin") as auth gates are strictly forbidden. Limited role comparisons are allowed for non-gating logic — SQL/query row filtering (e.g., anisAdminflag driving awhereclause), UI display flags/badges, business-logic preconditions — but each such usage must be annotated with// permissions-audit-allow: <reason>so the matrix audit recognizes the exception. The matrix atsrc/lib/permissions/matrix.tsis the single source of truth and must remain perfectly synced with actual enforcement. - Supabase SSR Contract (CORE-SSR-001/002): Use
~/lib/supabase/server'screateClient()for user-scoped server work. Always callawait supabase.auth.getUser()immediately after client creation, with no logic in between. Do not hand-build a user-scoped SSR client from@supabase/supabase-js— go through~/lib/supabase/server. Importing types or specific utilities from@supabase/supabase-jsis fine, andsrc/lib/supabase/admin.tslegitimately usescreateClientfrom@supabase/supabase-jsto build the server-only, service-role admin client. - Validate ALL Inputs (CORE-SEC-002): Use Zod for all form data and user inputs. Never trust
FormDataor query parameters without validation. - CSP with Nonces (CORE-SEC-003/004): Dynamic nonces are generated via
middleware.tsfor script execution. Do not use'unsafe-inline'or'unsafe-eval'for scripts. Static security headers are set innext.config.ts. - PII and Email Privacy (CORE-SEC-007): Email addresses are PII. Display user emails only in admin views and the user's own settings page. Everywhere else: names, "Anonymous", or roles.
- Host Consistency (CORE-SEC-008): Use
localhostfor all local URLs, config,.env*, and PlaywrightbaseURL. Mixinglocalhostand127.0.0.1breaks cookies and SSR auth.
1. Authentication & Supabase SSR
Server Client Creation (CORE-SSR-001)
Always import and use the custom client creator from ~/lib/supabase/server. Only a small allowlist of non-test modules touches @supabase/ssr directly: src/lib/supabase/server.ts (the SSR wrapper itself), src/lib/supabase/middleware.ts (token refresh in updateSession), and src/app/(auth)/auth/callback/route.ts (custom cookie handling so OAuth tokens are written to the response). App code outside this allowlist must go through ~/lib/supabase/server. (Tests may mock @supabase/ssr — e.g. src/lib/supabase/middleware.test.ts — which is fine.)
Immediate getUser Check (CORE-SSR-002)
To prevent timing and token invalidation issues, await supabase.auth.getUser() must be the very next line after client instantiation.
Correct Pattern (Server Action or Route):
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser(); // Called immediately!
if (!user) redirect("/login");
Middleware Token Refresh (CORE-SSR-003, CORE-SSR-005)
Token refresh is managed automatically in middleware.ts (at the repo root — not src/middleware.ts) via updateSession(request) (implemented in src/lib/supabase/middleware.ts). This function invokes supabase.auth.getUser() to refresh expired tokens and updates response cookies for the browser.
Return the response object from updateSession as-is (CORE-SSR-005). Don't mutate, rewrap, or copy headers into a fresh NextResponse — that strips the Set-Cookie headers that carry refreshed session tokens, and the next request will see an anonymous user.
CSP Authoring (CORE-SEC-003/004)
The root middleware.ts also sets the Content-Security-Policy. Things to know before modifying it:
script-srcposture: production is nonce-only —'self' 'nonce-<uuid>' 'strict-dynamic', no host allowlist. Preview addshttps://vercel.liveandhttps://challenges.cloudflare.comfor the Vercel toolbar and Turnstile widget. Never add'unsafe-inline'or'unsafe-eval'.- Per-request nonce:
middleware.tscallscrypto.randomUUID()and sets the nonce onContent-Security-Policy('nonce-<uuid>') plus anx-nonceresponse header. Thex-nonceheader is set for any inline-script use case; there is no consumer insrc/today, so if you add an inline<script>you must readx-nonceyourself and set thenonceattribute. - Already allowlisted:
challenges.cloudflare.com(Turnstile CAPTCHA) — inconnect-srcandframe-srcin both branches, and additionally inscript-srconly on preview. Supabase URL + WS URL inconnect-src. Noteconnect-srcallows bothlocalhost:*and127.0.0.1:*in both branches (production included), so don't describe that as dev-only. - Adding a new external host: add to the appropriate directive in the production branch first, mirror to the preview branch only if needed. Default to deny.
Auth Callback Route (CORE-SSR-004)
The OAuth flow redirects through src/app/(auth)/auth/callback/route.ts which handles the code-to-session exchange (exchangeCodeForSession) and OTP verification (verifyOtp).
Database Profile Trigger (CORE-SSR-006)
Never create user profiles manually in Server Actions. Profiles are created atomically via the handle_new_user SQL trigger on auth.users insert, ensuring compatibility with all auth methods including OAuth.
Don't Query auth.users Directly (CORE-SSR-007)
Application code reads user data from user_profiles (mirrored from auth.users via the handle_new_user trigger). Do not write Drizzle queries or raw SQL against auth.users in Server Actions, services, or route handlers — the schema is internal to Supabase and can change between releases. If you need to look up an auth record by email (e.g., detecting an orphaned auth.users row after a trigger failure), use the Supabase Admin API: createAdminClient().auth.admin.listUsers(...) and filter in JS.
Allowed exceptions:
- Database triggers and
supabase/seed.sql. - Test bootstrapping (
src/test/setup/pglite.tsand integration test fixtures, which use theauthUsersDrizzle wrapper to seed pairedauth.users+user_profilesrows).
Server→Client Data Minimization (CORE-SEC-006)
The React Server Components payload is visible in page source — view-source: on any authenticated page reveals every prop passed to "use client" components. Map server-fetched data to a minimal shape before the boundary; never pass full ORM/domain objects (UnifiedUser, full user_profiles records, raw issue rows with reporterEmail, etc.) as Client Component props.
Practical pattern: define a XyzProps interface listing only the fields the component actually uses, then build it from the full object in the Server Component. Combine with CORE-SEC-007: even authenticated users should not see other users' emails in the RSC payload outside admin views.
2. Authorization & Permissions Framework (CORE-ARCH-008)
The permissions system is defined in src/lib/permissions/matrix.ts (single source of truth) and checked via helpers in src/lib/permissions/helpers.ts or React hooks in src/lib/permissions/hooks.ts.
The permissions-audit-allow annotation contract is enforced, not documentation. scripts/audit/no-hardcoded-role-checks.sh scans src/ (excluding the permissions module and tests) for role === "<role>" comparisons and fails on any that lack // permissions-audit-allow: <reason> on the same line, the line directly above, or the line directly below. The audit is wired into pnpm run check as audit:role-checks — preflight will reject unannotated gates.
Permission Helpers
Always use the following functions for permission gating and auditing:
- Server Helpers (
src/lib/permissions/helpers.ts):getAccessLevel(role): Resolves a database role string (or null) to anAccessLevel("unauthenticated", "guest", "member", "technician", "admin").checkPermission(permissionId, accessLevel, context): Checks if a permission is granted. Handles conditional permissions ('own','owner') usingOwnershipContext.checkPermissions(permissionIds, accessLevel, context): Returns true if all permissions are granted.checkAnyPermission(permissionIds, accessLevel, context): Returns true if any of the permissions are granted.getGrantedPermissions(accessLevel, context): Retrieves list of granted permission IDs.getPermissionState(permissionId, accessLevel, context): Returns a discriminated union —{ allowed: true }or{ allowed: false; reason: "unauthenticated" | "role" | "ownership" }. Narrow onallowedbefore readingreason; it only exists on the denied branch. (TheusePermissionStateclient hook flattens this to{ allowed: boolean; reason: string | null }for convenience.)getPermissionDeniedReason(permissionId, accessLevel, context): Returns a human-readable tooltip string for disabled actions.isConditionalPermission(permissionId, accessLevel): Checks if a permission's matrix value is conditional ('own'/'owner') and therefore requiresOwnershipContextto evaluate.getRawPermissionValue(permissionId, accessLevel): Returns the raw matrix value (true,false,'own', or'owner') before ownership resolution. Use this only when you need to introspect the matrix entry itself (e.g., to choose between two UI states); for actual access decisions, always go throughcheckPermission/getPermissionState.
- Client React Hooks (
src/lib/permissions/hooks.ts):usePermission(permissionId, user, context)usePermissionState(permissionId, user, context)usePermissions(permissionIds, user, context)usePermissionStates(permissionIds, user, context)useAccessLevel(user)useIsAuthenticated(user)useIsConditionalPermission(permissionId, user)useRawPermission(permissionId, user)
3. Discord OAuth Integration (extensible to other providers)
Discord is the only provider currently registered. The OAuth machinery (provider registry, unlink guard, callback redirect handling) is structured so additional providers can be added by appending entries to the registry, but ProviderKey resolves to "discord" today.
Multi-Provider Registry (src/lib/auth/providers.ts)
Supported providers are defined in the providers map, which implements the Provider interface:
key: Stable key matching Supabase (e.g.,"discord").displayName: User-facing name (e.g.,"Discord").scopes: OAuth scopes requested (e.g.,"identify email").iconComponent: SVG icon for the button.isAvailable(): Returnstrueif credentials (e.g., client ID and secret) exist in environment variables.
Unlink Identity Guard (src/lib/auth/identity-guards.ts)
To prevent users from locking themselves out, canUnlinkIdentity enforces that unlinking is only allowed if the user has at least one other active login method (e.g., another provider or a password):
export function canUnlinkIdentity(
identities: readonly UserIdentity[],
providerKey: ProviderKey
): UnlinkCheck;
Discord Identity Mirroring
During OAuth login or linking, the callback route src/app/(auth)/auth/callback/route.ts invokes syncDiscordIdentity(supabase) to extract the Discord user ID from getUserIdentities() and mirror it into user_profiles.discordUserId in the database. syncDiscordIdentity is defined as an internal helper inside the callback route — it is not exported and is not callable from elsewhere.
Dynamic Redirects
Redirect target URLs are normalized using resolveRedirectPath in the callback route to enforce that redirects only point to internal paths or the configured getSiteUrl(), preventing open redirect vulnerabilities.
4. Code Examples
Server Action with Auth & Permission Gates
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { createClient } from "~/lib/supabase/server";
import { checkPermission, getAccessLevel } from "~/lib/permissions/helpers";
import { db } from "~/server/db";
import { issues } from "~/server/db/schema";
import { eq } from "drizzle-orm";
const updateIssueSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1, "Title is required"),
severity: z.enum(["cosmetic", "minor", "major", "unplayable"]),
});
export async function updateIssueAction(formData: FormData) {
// 1. Create client and fetch user immediately (CORE-SSR-001, CORE-SSR-002)
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
// 2. Fetch the user's role from the profile
const profile = await db.query.userProfiles.findFirst({
where: (profiles, { eq }) => eq(profiles.id, user.id),
});
const accessLevel = getAccessLevel(profile?.role);
// 3. Validate form inputs (CORE-SEC-002)
const validation = updateIssueSchema.safeParse({
id: formData.get("id"),
title: formData.get("title"),
severity: formData.get("severity"),
});
if (!validation.success) {
return { ok: false, error: "Invalid form input" };
}
const data = validation.data;
// 4. Fetch resource for ownership context
const issue = await db.query.issues.findFirst({
where: eq(issues.id, data.id),
});
if (!issue) {
return { ok: false, error: "Issue not found" };
}
// 5. Check permissions through the matrix helper (CORE-ARCH-008)
// checkPermission returns boolean — it resolves "own"/"owner" conditional
// permissions internally against the OwnershipContext. Always pass
// { userId, reporterId } (issues) or { userId, machineOwnerId } (machines)
// when the permission may be conditional; otherwise the call denies by
// default. For UI gating with denial-reason tooltips, prefer
// getPermissionState(...).allowed, which returns { allowed, reason }.
const isAllowed = checkPermission("issues.update.reporting", accessLevel, {
userId: user.id,
reporterId: issue.reportedBy,
});
if (!isAllowed) {
return { ok: false, error: "Unauthorized" };
}
// 6. Proceed with database update
await db
.update(issues)
.set({
title: data.title,
severity: data.severity,
})
.where(eq(issues.id, data.id));
return { ok: true };
}
Client Component with Hooks
"use client";
import { usePermissionState, type PermissionUser, type IssueContext } from "~/lib/permissions/hooks";
interface IssueEditorProps {
user: PermissionUser;
issue: IssueContext;
}
export function IssueEditor({ user, issue }: IssueEditorProps) {
// Check permission state with tooltip reason if denied
const { allowed, reason } = usePermissionState("issues.update.reporting", user, {
issue,
});
return (
<div className="flex flex-col gap-2">
<input
type="text"
disabled={!allowed}
placeholder="Edit issue title..."
className="border p-2 disabled:bg-gray-100 disabled:cursor-not-allowed"
title={reason ?? undefined}
/>
{!allowed && reason && (
<span className="text-xs text-destructive">{reason}</span>
)}
</div>
);
}
OAuth Linking Action
OAuth linking must redirect through /auth/callback?next=/settings so that exchangeCodeForSession persists the link state:
export async function linkProviderAction(rawKey: string): Promise<void> {
const result = await runLinkProvider(rawKey);
if (!result.ok) {
redirect(`/settings?oauth_error=${encodeURIComponent(result.code)}`);
}
// Redirect to Supabase authorize url
redirect(result.value.redirectUrl);
}
Input Sanitization
Don't roll a new sanitize-html allowlist. The codebase has a shared config — ~/lib/sanitize-html-config exports NON_TEXT_TAGS, the canonical set of raw-text tags that must be stripped (covered by the test in sanitize-html-config.test.ts). The shared config is consumed by src/lib/markdown.ts, src/lib/tiptap/render.ts, and src/lib/notifications/channels/email-channel.ts.
For the common cases:
- Markdown-from-user-input → safe HTML: call
renderMarkdownToHtml(...)from~/lib/markdown(double-sanitizes after the markdown renderer runs). - TipTap ProseMirror JSON → safe HTML for display: use the renderer in
~/lib/tiptap/render(this is whatRichTextDisplayuses; the comment there reads "Output is double-sanitized — the renderer escapes all text"). - Raw HTML that genuinely needs sanitization in a new place: import
sanitizeHtmlfromsanitize-htmlANDNON_TEXT_TAGSfrom~/lib/sanitize-html-config, and passnonTextTags: NON_TEXT_TAGSalongside whateverallowedTags/allowedAttributesyou need. The sharedNON_TEXT_TAGSconstant is what keeps<script>,<style>,<textarea>, etc. from leaking through; replicating an inline allowlist that omits it is a footgun.
Security Checklist
Before deploying or merging security-sensitive changes, verify:
- Auth Checks:
createClient()is immediately followed byauth.getUser()(CORE-SSR-002) in all Server Actions/routes. - Authorization: Every action is protected using the permissions matrix via
checkPermission()(CORE-ARCH-008). No hardcoded role gates;pnpm run check(audit:role-checks) must pass. -
auth.usersdiscipline: No application-code queries againstauth.usersoutside the sanctioned exceptions (CORE-SSR-007). - Server→Client minimization: Client Component props are minimal shapes, not raw ORM rows (CORE-SEC-006).
- Email Privacy: User email addresses are never displayed outside of admin views or settings (CORE-SEC-007).
- Input Validation: All form inputs are validated using Zod (CORE-SEC-002).
- Sanitization: Any new sanitize-html call uses
NON_TEXT_TAGSfrom~/lib/sanitize-html-config; prefer the existingrenderMarkdownToHtml/ tiptap renderer over rolling a new allowlist. - Hostnames: No hardcoded hostnames/ports used; local dev runs strictly on
localhost(CORE-SEC-008). - CSP Config: Dynamic CSP nonce generated via root
middleware.ts(CORE-SEC-004); no'unsafe-inline'for script-src; new external hosts added to the production branch by default. - Middleware response:
updateSession's response is returned as-is (CORE-SSR-005) — no rewrap, no header copy. - Identities Safeguard: Manual unlinking verifies multiple identities remain via
canUnlinkIdentity. - Drizzle Migrations: Schema updates are managed via generated SQL migrations only (CORE-ARCH-009).
References
- Security Details:
docs/SECURITY.md - Core Guidelines:
docs/NON_NEGOTIABLES.md(specificallyCORE-SEC-*andCORE-SSR-*rules) - Design System Rules:
pinpoint-design-bible - Database Schema:
src/server/db/schema.ts