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 executes

None 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.

Context

Bucket

Allowed Types

Max Size

User avatars

avatars

JPEG, PNG, WebP, GIF

2MB

Clinic logos

logos

JPEG, PNG, WebP, SVG

2MB

Favicon

logos

JPEG, PNG, WebP, SVG

500KB

Medical attachments

medical-attachments

PDF, JPEG, PNG, GIF, WebP, DOC, DOCX

10MB

Landing page assets

landing-assets

JPEG, PNG, WebP, SVG

5MB

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_KEY is set and never prefixed with NEXT_PUBLIC_

  • CRON_SECRET is a long random string — minimum 32 characters

  • CARENOVA_DEBUG is removed or set to 0

  • DATABASE_URL uses the transaction pooler on port 6543 with ?pgbouncer=true

  • NEXT_PUBLIC_SITE_URL is 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-auth is configured to run daily

  • The license is activated at /setup

Code hygiene:

  • No console.log statements output sensitive data

  • No .env.local file is committed to version control — confirm .gitignore includes it

  • DIRECT_DATABASE_URL is not committed anywhere