Back to blog
healthcarebeginner

Project: HIPAA-Compliant Patient Portal

Build a HIPAA-compliant patient portal with Next.js, Supabase, and AWS: secure auth, PHI access controls, appointment booking, document upload, and audit logging.

Asma HafeezApril 17, 20266 min read
healthcarehipaaprojectnextjssupabaseaws
Share:𝕏

Project: HIPAA-Compliant Patient Portal

Build a real patient portal where patients log in, view their records, book appointments, and securely upload documents. Every PHI access is logged, all data is encrypted.


Project Structure

patient-portal/
├── src/
│   ├── app/
│   │   ├── (auth)/
│   │   │   ├── login/page.tsx
│   │   │   └── register/page.tsx
│   │   ├── (portal)/
│   │   │   ├── dashboard/page.tsx
│   │   │   ├── appointments/page.tsx
│   │   │   ├── records/page.tsx
│   │   │   └── messages/page.tsx
│   │   └── api/
│   │       ├── appointments/route.ts
│   │       ├── documents/route.ts
│   │       └── messages/route.ts
│   ├── lib/
│   │   ├── supabase/
│   │   │   ├── client.ts
│   │   │   └── server.ts
│   │   └── audit.ts
│   └── middleware.ts

Database Schema

SQL
-- Run in Supabase SQL editor

-- Patient profiles (extends auth.users)
CREATE TABLE patient_profiles (
    id          UUID PRIMARY KEY REFERENCES auth.users(id),
    mrn         TEXT UNIQUE NOT NULL,  -- Medical Record Number
    first_name  TEXT NOT NULL,
    last_name   TEXT NOT NULL,
    dob         DATE NOT NULL,
    phone       TEXT,
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Appointments
CREATE TABLE appointments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    patient_id      UUID NOT NULL REFERENCES auth.users(id),
    provider_name   TEXT NOT NULL,
    appointment_type TEXT NOT NULL,
    scheduled_at    TIMESTAMPTZ NOT NULL,
    status          TEXT NOT NULL DEFAULT 'scheduled'
                        CHECK (status IN ('scheduled', 'confirmed', 'completed', 'cancelled')),
    notes           TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Secure messages
CREATE TABLE secure_messages (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    patient_id  UUID NOT NULL REFERENCES auth.users(id),
    sender_id   UUID NOT NULL REFERENCES auth.users(id),
    subject     TEXT NOT NULL,
    body        TEXT NOT NULL,
    is_read     BOOLEAN DEFAULT FALSE,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- PHI audit log
CREATE TABLE phi_audit_log (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID REFERENCES auth.users(id),
    patient_id  UUID,
    action      TEXT NOT NULL,
    resource    TEXT NOT NULL,
    ip_address  TEXT,
    user_agent  TEXT,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Enable RLS
ALTER TABLE patient_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE secure_messages ENABLE ROW LEVEL SECURITY;

-- Patients see only their own data
CREATE POLICY "patients_own_profile"   ON patient_profiles FOR ALL USING (id = auth.uid());
CREATE POLICY "patients_own_appts"     ON appointments     FOR ALL USING (patient_id = auth.uid());
CREATE POLICY "patients_own_messages"  ON secure_messages  FOR ALL USING (patient_id = auth.uid());

Supabase Client Setup

TYPESCRIPT
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createServerSupabase() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll:    () => cookieStore.getAll(),
        setAll:    (cs) => cs.forEach(({ name, value, options }) =>
                     cookieStore.set(name, value, options))
      }
    }
  )
}

Authentication Middleware

TYPESCRIPT
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  const response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { getAll: () => request.cookies.getAll(),
                 setAll: (cs) => cs.forEach(({ name, value, options }) =>
                   response.cookies.set(name, value, options)) } }
  )

  const { data: { user } } = await supabase.auth.getUser()

  // Redirect unauthenticated users to login
  if (!user && request.nextUrl.pathname.startsWith('/(portal)')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return response
}

export const config = {
  matcher: ['/(portal)/:path*', '/api/:path*']
}

Dashboard Page

TYPESCRIPT
// src/app/(portal)/dashboard/page.tsx
import { createServerSupabase } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { logPhiAccess } from '@/lib/audit'

export default async function DashboardPage() {
  const supabase = await createServerSupabase()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  // Fetch patient profile — RLS ensures they only see their own
  const { data: profile } = await supabase
    .from('patient_profiles')
    .select('first_name, last_name, mrn')
    .single()

  const { data: upcomingAppts } = await supabase
    .from('appointments')
    .select('*')
    .eq('status', 'scheduled')
    .gte('scheduled_at', new Date().toISOString())
    .order('scheduled_at')
    .limit(3)

  await logPhiAccess(user.id, user.id, 'READ', 'dashboard')

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">
        Welcome, {profile?.first_name}
      </h1>
      <p className="text-sm text-muted-foreground mb-6">MRN: {profile?.mrn}</p>

      <h2 className="text-lg font-semibold mb-3">Upcoming Appointments</h2>
      {upcomingAppts?.map(appt => (
        <div key={appt.id} className="p-4 border rounded-lg mb-2">
          <p className="font-medium">{appt.appointment_type}</p>
          <p className="text-sm text-muted-foreground">
            {new Date(appt.scheduled_at).toLocaleString()}{appt.provider_name}
          </p>
        </div>
      ))}
    </div>
  )
}

Document Upload API

TYPESCRIPT
// src/app/api/documents/route.ts
import { createServerSupabase } from '@/lib/supabase/server'
import { logPhiAccess } from '@/lib/audit'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const supabase = await createServerSupabase()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const form = await request.formData()
  const file = form.get('file') as File
  const label = form.get('label') as string

  if (!file) return NextResponse.json({ error: 'No file' }, { status: 400 })

  // Validate file type — only PDFs and images
  const allowed = ['application/pdf', 'image/jpeg', 'image/png']
  if (!allowed.includes(file.type))
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })

  // Validate size (10MB max)
  if (file.size > 10 * 1024 * 1024)
    return NextResponse.json({ error: 'File too large' }, { status: 400 })

  const path  = `${user.id}/${Date.now()}-${file.name}`
  const bytes = await file.arrayBuffer()

  const { error } = await supabase.storage
    .from('patient-documents')
    .upload(path, bytes, { contentType: file.type })

  if (error) return NextResponse.json({ error: error.message }, { status: 500 })

  await logPhiAccess(user.id, user.id, 'UPLOAD', `document:${label}`)

  return NextResponse.json({ path, label })
}

Audit Logging

TYPESCRIPT
// src/lib/audit.ts
import { createServerSupabase } from './supabase/server'
import { headers } from 'next/headers'

export async function logPhiAccess(
  userId:    string,
  patientId: string,
  action:    string,
  resource:  string
) {
  const supabase    = await createServerSupabase()
  const headersList = await headers()

  await supabase.from('phi_audit_log').insert({
    user_id:    userId,
    patient_id: patientId,
    action,
    resource,
    ip_address: headersList.get('x-forwarded-for') ?? 'unknown',
    user_agent: headersList.get('user-agent') ?? 'unknown'
  })
}

Environment Variables

Bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# Never expose these publicly
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

HIPAA Compliance Checklist

Authentication:
  ☐ Automatic session timeout (15-30 minutes of inactivity)
  ☐ Strong password requirements enforced
  ☐ MFA optional but encouraged for providers

Data:
  ☐ RLS enabled on all PHI tables
  ☐ Storage bucket: private, RLS-protected
  ☐ No PHI in URL parameters or browser history

Audit:
  ☐ Every PHI access logged with user, patient, action, timestamp, IP
  ☐ Audit logs not deletable by regular users
  ☐ Logs retained per HIPAA requirements (6 years)

Infrastructure:
  ☐ Supabase HIPAA tier with signed BAA
  ☐ HTTPS enforced everywhere
  ☐ Environment variables for all credentials

Key Takeaways

  1. RLS does the access control — every PHI table needs policies; test them with a real user, not the service role
  2. Audit every PHI access — who accessed what, when, from where — this is a HIPAA audit requirement
  3. File uploads need validation — type, size, and path isolation per user
  4. Middleware protects routes — unauthenticated requests never reach portal pages or API routes
  5. Get a signed BAA from Supabase before storing any real patient data — it's a legal requirement under HIPAA

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.