Security Reference
A consolidated reference for CareNova's security model — server-side enforcement, the service role key, file upload restrictions, and row-level security.
Written By Dev010
Last updated 19 days ago
CareNova is built with security enforced at the server layer — not just the UI. This article consolidates the key security behaviors, configuration requirements, and boundaries developers need to understand before deploying or extending the platform.
Server-Side Enforcement
Every mutation in CareNova goes through a Server Action. Every Server Action follows the same enforcement sequence before touching the database:
1. getCachedCurrentUser()
→ confirms a valid authenticated session exists
→ returns null if no session — action exits
2. requireRole() or requirePermission()
→ confirms the user has the right role or permission
→ throws PermissionDeniedError if not
3. schema.parse(input)
→ Zod re-validates all input on the server
→ throws if input does not match the schema
4. db mutation executesNone of these steps are optional. Hiding a button in the UI does not protect a mutation — the server action enforces access independently of what the client renders. A request that bypasses the UI entirely — via curl, Postman, or any direct call — hits the same enforcement chain.
// Every server action follows this pattern
"use server";
export async function deletePatient(id: string) {
const user = await getCachedCurrentUser();
if (!user) return { success: false, error: "Unauthorized" };
requireRole(user.role, ["admin"]);
const data = deletePatientSchema.parse({ id });
await db.delete(patients).where(eq(patients.id, data.id));
revalidatePath("/dashboard/patients");
return { success: true };
}requireRole vs requirePermission
CareNova has two enforcement functions and uses them in different contexts.
requireRole(userRole, allowedRoles[]) is a hard role check. It throws if the user's role is not in the allowed list. Use this for actions that are permanently restricted to specific roles regardless of how permissions are configured — for example, only admins can ever approve users or modify permissions.
requirePermission(permissionKey) checks the role_permissions table for the current user's role. It throws a PermissionDeniedError if the permission is not granted. Use this for actions that admins can configure access to via the Permissions UI.
Admin accounts bypass requirePermission entirely — hasPermission always returns true for the admin role without querying the database.
The Service Role Key
The SUPABASE_SERVICE_ROLE_KEY environment variable gives unrestricted access to your Supabase database — it bypasses Row Level Security and all auth checks. It is the most sensitive credential in the application.
Rules for the service role key:
It must never be exposed to the client. It is a server-only variable — no NEXT_PUBLIC_ prefix, never passed to a Client Component, never returned in an API response.
It is only used in two contexts in CareNova — the Supabase admin client in lib/supabase/admin.ts for operations that require elevated privileges (user approval, storage bucket management), and seed/setup scripts that run server-side only.
// lib/supabase/admin.ts
// This client is never instantiated on the client side
export function getSupabaseAdmin() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}If this key is ever accidentally committed to version control or exposed publicly, rotate it immediately in your Supabase project settings under Project Settings → API.
For all standard server actions and server components, use the regular Supabase client from lib/supabase/server.ts — not the admin client. The regular client operates within the authenticated user's session and respects all access boundaries.
File Upload Security
File uploads in CareNova go directly to Supabase Storage from server actions — never through the Next.js server as a file buffer. Each upload context enforces its own type and size restrictions before the file reaches storage.
These checks are enforced in the server action before the upload is attempted. A file that fails the type or size check is rejected with an error — it never reaches Supabase Storage.
All storage buckets are configured as Public — uploaded files are accessible via their storage URL without authentication. This is intentional for clinic assets, logos, and landing images that need to be publicly visible.
For medical attachments this means the file URL is technically publicly accessible if known. CareNova does not implement signed URLs for medical attachments in the current version. If your deployment requires strict medical file privacy, configure the medical-attachments bucket as Private in Supabase Storage and update lib/actions/medical-records-actions.ts to generate signed URLs when retrieving attachment links.
Authentication Security
The following protections are active by default on every installation.
Rate limiting blocks login attempts after 5 failures by email or 10 failures by IP within a 15-minute window. Blocked attempts never reach Supabase Auth — they are rejected before the auth call is made, which prevents unnecessary load on the auth service.
Password policy enforces a minimum of 8 characters with at least one uppercase letter, one lowercase letter, one number, and one special character. A list of common blocked passwords is checked on signup and password change.
Audit logging records every auth event — successful logins, failed attempts, logouts, user approvals and rejections — to the auth_audit_log table with timestamp, IP address, and user agent.
Session tracking records active sessions in user_sessions with expiry timestamps. Sessions can be revoked individually via SQL if a compromised account is detected.
Pending approval means new self-signup accounts cannot access the dashboard until an admin sets approved_at. This prevents unauthorized access from anyone who discovers the signup URL.
Full details for each of these systems are covered in the Authentication & Access Control collection.
Row Level Security (RLS)
CareNova's primary access control mechanism is application-level enforcement via requireRole and requirePermission in server actions — not Supabase Row Level Security policies.
RLS is not enabled on CareNova's application tables by default. The rationale is that all data access goes through server actions which enforce auth and permissions before executing any query. Direct database access is not exposed to the client — the Drizzle client runs exclusively on the server.
If you plan to expose any tables directly to the client via the Supabase JavaScript client or REST API — outside of CareNova's server action layer — you should enable RLS policies on those tables before doing so. Without RLS, the Supabase anon key can read and write any table directly.
The NEXT_PUBLIC_SUPABASE_ANON_KEY is safe to expose publicly only because CareNova does not make direct client-to-database calls. All queries go through server actions. If you change this architecture, revisit RLS before deploying.
To enable RLS on a table in Supabase:
-- Enable RLS on a table
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
-- Example policy — authenticated users only
CREATE POLICY "authenticated_read" ON patients
FOR SELECT
USING (auth.role() = 'authenticated');Security Checklist for Production
Before going live, confirm the following are in place:
Environment variables:
SUPABASE_SERVICE_ROLE_KEYis set and never prefixed withNEXT_PUBLIC_CRON_SECRETis a long random string — minimum 32 charactersCARENOVA_DEBUGis removed or set to0DATABASE_URLuses the transaction pooler on port 6543 with?pgbouncer=trueNEXT_PUBLIC_SITE_URLis set to the production domain — incorrect values break auth redirect URLs
Supabase configuration:
Email confirmation is enabled in Supabase Auth settings
Redirect URLs are set to the production domain only — remove localhost entries from production project
Storage buckets exist and are set to the correct visibility (Public for all five buckets in the default configuration)
Application behavior:
At least one admin account exists and has been approved
The cron job at
/api/cron/cleanup-authis configured to run dailyThe license is activated at
/setup
Code hygiene:
No
console.logstatements output sensitive dataNo
.env.localfile is committed to version control — confirm.gitignoreincludes itDIRECT_DATABASE_URLis not committed anywhere