DEV Community

Olamilekan Lamidi
Olamilekan Lamidi

Posted on

Security by Design in Healthcare Data Platforms

Healthcare data is among the most sensitive information a system can hold. Patient records, diagnostic results, treatment histories, and genomic data are not just private — they are regulated by laws that carry serious penalties for mishandling. HIPAA in the United States, GDPR in Europe, and Nigeria's NDPR all impose strict requirements on how healthcare data must be collected, stored, processed, and shared.

Security in healthcare is not something you add after the product works. It must be woven into every layer of the system from the first line of code. I have built health-tech platforms that handle sensitive patient data collection and clinical workflows, and the lessons from that work apply broadly to any system where data protection is not optional.

This article covers the architecture patterns, implementation strategies, and operational practices for building healthcare data platforms that are secure by design — with examples in both Laravel and Node.js.


Why Healthcare Systems Are Different

Most web applications can tolerate a brief security lapse and recover. Healthcare systems cannot. The consequences of a breach are uniquely severe:

  1. Patient harm: Leaked medical records can affect employment, insurance, and personal relationships. Unlike a leaked password, a disclosed medical condition cannot be reset.

  2. Regulatory penalties: HIPAA violations can result in fines up to $1.5 million per violation category per year. GDPR fines can reach 4% of global annual revenue.

  3. Operational shutdown: A breach in a healthcare system may require shutting down access to patient data while the investigation proceeds — directly affecting care delivery.

  4. Trust destruction: Patients who learn their data was compromised may refuse to use digital health tools, undermining the entire digital health ecosystem.

These consequences demand a security model that assumes breaches will be attempted and builds defences at every layer.


The Security Architecture

Healthcare data security operates on multiple layers:

[Client Layer]
  - End-to-end encryption for data in transit
  - Session management with short TTLs
  - Input validation and sanitisation

[Application Layer]
  - Authentication (MFA required)
  - Role-Based Access Control (RBAC)
  - Audit logging of every data access
  - Field-level encryption for sensitive data

[Data Layer]
  - Encryption at rest (AES-256)
  - Database access controls
  - Backup encryption
  - Data retention and purging policies

[Infrastructure Layer]
  - Network segmentation
  - Intrusion detection
  - Vulnerability scanning
  - Access logging and monitoring
Enter fullscreen mode Exit fullscreen mode

Authentication and Access Control

Healthcare systems require strong authentication and granular access control. Not every user should see every patient's data, and every access must be logged.

Laravel: Role-Based Access Control

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Permission extends Model
{
    protected $fillable = ['name', 'resource', 'action', 'conditions'];

    protected $casts = ['conditions' => 'array'];
}

class Role extends Model
{
    protected $fillable = ['name', 'description', 'level'];

    public function permissions()
    {
        return $this->belongsToMany(Permission::class);
    }
}
Enter fullscreen mode Exit fullscreen mode
namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Cache;

class AccessControlService
{
    public function canAccess(User $user, string $resource, string $action, array $context = []): bool
    {
        $permissions = $this->getUserPermissions($user);

        foreach ($permissions as $permission) {
            if ($permission->resource !== $resource) continue;
            if ($permission->action !== $action && $permission->action !== '*') continue;

            if ($permission->conditions) {
                if (!$this->evaluateConditions($permission->conditions, $context, $user)) {
                    continue;
                }
            }

            $this->logAccess($user, $resource, $action, $context, true);
            return true;
        }

        $this->logAccess($user, $resource, $action, $context, false);
        return false;
    }

    private function evaluateConditions(array $conditions, array $context, User $user): bool
    {
        foreach ($conditions as $condition) {
            switch ($condition['type']) {
                case 'own_patients_only':
                    if (($context['patient_id'] ?? null) &&
                        !$user->patients()->where('id', $context['patient_id'])->exists()) {
                        return false;
                    }
                    break;

                case 'department_match':
                    if (($context['department_id'] ?? null) !== $user->department_id) {
                        return false;
                    }
                    break;

                case 'time_restricted':
                    $now = now();
                    if ($now->hour < $condition['start_hour'] || $now->hour > $condition['end_hour']) {
                        return false;
                    }
                    break;
            }
        }

        return true;
    }

    private function getUserPermissions(User $user)
    {
        return Cache::remember(
            "user_permissions:{$user->id}",
            now()->addMinutes(15),
            fn () => $user->roles()->with('permissions')->get()->pluck('permissions')->flatten()
        );
    }

    private function logAccess(User $user, string $resource, string $action, array $context, bool $granted): void
    {
        \App\Models\AccessLog::create([
            'user_id' => $user->id,
            'resource' => $resource,
            'action' => $action,
            'context' => $context,
            'granted' => $granted,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'accessed_at' => now(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Node.js: Middleware-Based Access Control

import { Request, Response, NextFunction } from 'express';

interface AccessRule {
  resource: string;
  action: string;
  conditions?: Array<{
    type: string;
    [key: string]: unknown;
  }>;
}

export function requireAccess(resource: string, action: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = (req as any).user;

    if (!user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const context = {
      patient_id: req.params.patientId,
      department_id: req.params.departmentId,
      ip_address: req.ip,
    };

    const granted = await checkAccess(user, resource, action, context);

    await logAccess({
      userId: user.id,
      resource,
      action,
      context,
      granted,
      ipAddress: req.ip!,
      userAgent: req.headers['user-agent'] || '',
      accessedAt: new Date(),
    });

    if (!granted) {
      return res.status(403).json({
        error: 'Access denied',
        resource,
        action,
      });
    }

    next();
  };
}

async function checkAccess(
  user: any,
  resource: string,
  action: string,
  context: Record<string, unknown>
): Promise<boolean> {
  const permissions = await getUserPermissions(user.id);

  return permissions.some((perm: AccessRule) => {
    if (perm.resource !== resource) return false;
    if (perm.action !== action && perm.action !== '*') return false;

    if (perm.conditions) {
      return perm.conditions.every((condition) =>
        evaluateCondition(condition, context, user)
      );
    }

    return true;
  });
}

function evaluateCondition(
  condition: { type: string; [key: string]: unknown },
  context: Record<string, unknown>,
  user: any
): boolean {
  switch (condition.type) {
    case 'own_patients_only':
      return user.patientIds?.includes(context.patient_id) ?? false;

    case 'department_match':
      return user.departmentId === context.department_id;

    default:
      return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Apply to routes:

router.get(
  '/patients/:patientId/records',
  requireAccess('patient_records', 'read'),
  patientRecordController.list
);

router.post(
  '/patients/:patientId/records',
  requireAccess('patient_records', 'write'),
  patientRecordController.create
);

router.get(
  '/patients/:patientId/records/:recordId',
  requireAccess('patient_records', 'read'),
  patientRecordController.show
);
Enter fullscreen mode Exit fullscreen mode

Field-Level Encryption

Not all data in a patient record is equally sensitive. Name and date of birth require protection, but appointment times may not. Field-level encryption encrypts specific fields within a record rather than encrypting the entire database:

Laravel: Encrypted Attributes

namespace App\Models\Traits;

use Illuminate\Support\Facades\Crypt;

trait EncryptsHealthData
{
    protected static array $encryptedFields = [];

    public function getAttribute($key)
    {
        $value = parent::getAttribute($key);

        if (in_array($key, static::$encryptedFields) && $value !== null) {
            try {
                return Crypt::decryptString($value);
            } catch (\Exception) {
                return $value;
            }
        }

        return $value;
    }

    public function setAttribute($key, $value)
    {
        if (in_array($key, static::$encryptedFields) && $value !== null) {
            $value = Crypt::encryptString($value);
        }

        return parent::setAttribute($key, $value);
    }

    public static function searchEncrypted(string $field, string $value): ?static
    {
        if (!in_array($field, static::$encryptedFields)) {
            return static::where($field, $value)->first();
        }

        $blindIndex = hash('sha256', strtolower(trim($value)) . config('app.encryption_salt'));

        return static::where("{$field}_index", $blindIndex)->first();
    }
}

class PatientRecord extends Model
{
    use EncryptsHealthData;

    protected static array $encryptedFields = [
        'diagnosis',
        'treatment_notes',
        'medication_details',
        'lab_results',
        'genetic_data',
    ];

    protected $fillable = [
        'patient_id', 'record_type', 'diagnosis', 'diagnosis_index',
        'treatment_notes', 'medication_details', 'lab_results',
        'genetic_data', 'provider_id', 'facility_id', 'recorded_at',
    ];
}
Enter fullscreen mode Exit fullscreen mode

Node.js: Encryption Service

import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');

export function encryptField(plaintext: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag().toString('hex');

  return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}

export function decryptField(ciphertext: string): string {
  const [ivHex, authTagHex, encrypted] = ciphertext.split(':');

  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(authTagHex, 'hex');
  const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

export function createBlindIndex(value: string): string {
  return crypto
    .createHmac('sha256', process.env.INDEX_KEY!)
    .update(value.toLowerCase().trim())
    .digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

Prisma Middleware for Automatic Encryption

import { PrismaClient } from '@prisma/client';

const ENCRYPTED_FIELDS: Record<string, string[]> = {
  PatientRecord: ['diagnosis', 'treatmentNotes', 'medicationDetails', 'labResults'],
};

const prisma = new PrismaClient();

prisma.$use(async (params, next) => {
  const model = params.model;
  if (!model || !ENCRYPTED_FIELDS[model]) return next(params);

  const fields = ENCRYPTED_FIELDS[model];

  if (['create', 'update', 'upsert'].includes(params.action)) {
    const data = params.args.data || params.args.create;
    if (data) {
      for (const field of fields) {
        if (data[field] !== undefined && data[field] !== null) {
          data[field] = encryptField(data[field]);
        }
      }
    }
  }

  const result = await next(params);

  if (result && ['findFirst', 'findUnique', 'findMany'].includes(params.action)) {
    const records = Array.isArray(result) ? result : [result];
    for (const record of records) {
      for (const field of fields) {
        if (record[field]) {
          try {
            record[field] = decryptField(record[field]);
          } catch {
            // Field may not be encrypted (legacy data)
          }
        }
      }
    }
  }

  return result;
});
Enter fullscreen mode Exit fullscreen mode

Comprehensive Audit Logging

Every access to patient data must be logged. This is not optional — it is a regulatory requirement. The audit log answers: who accessed what data, when, from where, and why.

Laravel Audit Middleware

class AuditPatientAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $patientId = $request->route('patientId') ?? $request->input('patient_id');

        if ($patientId) {
            AuditLog::create([
                'user_id' => $request->user()->id,
                'user_role' => $request->user()->primaryRole()->name,
                'action' => $request->method() . ' ' . $request->path(),
                'resource_type' => 'patient_data',
                'resource_id' => $patientId,
                'ip_address' => $request->ip(),
                'user_agent' => $request->userAgent(),
                'response_code' => $response->getStatusCode(),
                'request_params' => $this->sanitiseParams($request->all()),
                'session_id' => $request->session()->getId(),
                'accessed_at' => now(),
            ]);
        }

        return $response;
    }

    private function sanitiseParams(array $params): array
    {
        $sensitive = ['password', 'ssn', 'credit_card', 'diagnosis'];
        return collect($params)
            ->map(fn ($value, $key) => in_array($key, $sensitive) ? '[REDACTED]' : $value)
            ->all();
    }
}
Enter fullscreen mode Exit fullscreen mode

Data Retention and Purging

Healthcare regulations specify minimum and maximum retention periods. Data must be available for the required period and securely destroyed afterward:

class DataRetentionService
{
    private const RETENTION_POLICIES = [
        'patient_records' => ['min_years' => 7, 'max_years' => 10],
        'audit_logs' => ['min_years' => 6, 'max_years' => 7],
        'session_data' => ['min_years' => 0, 'max_years' => 1],
        'consent_records' => ['min_years' => 10, 'max_years' => 15],
    ];

    public function enforceRetention(): array
    {
        $results = [];

        foreach (self::RETENTION_POLICIES as $dataType => $policy) {
            $cutoff = now()->subYears($policy['max_years']);

            $count = $this->getModel($dataType)
                ->where('created_at', '<', $cutoff)
                ->count();

            if ($count > 0) {
                $this->archiveAndPurge($dataType, $cutoff);
                $results[$dataType] = ['purged' => $count, 'cutoff' => $cutoff->toDateString()];
            }
        }

        return $results;
    }

    private function archiveAndPurge(string $dataType, $cutoff): void
    {
        $model = $this->getModel($dataType);
        $records = $model->where('created_at', '<', $cutoff)->cursor();

        $archivePath = sprintf(
            'retention-archive/%s/%s',
            $dataType,
            now()->format('Y-m-d')
        );

        foreach ($records->chunk(1000) as $chunk) {
            Storage::disk('s3')->put(
                "{$archivePath}/batch-" . uniqid() . '.json.enc',
                Crypt::encryptString($chunk->toJson())
            );
        }

        $model->where('created_at', '<', $cutoff)->delete();

        AuditLog::create([
            'action' => 'data_retention_purge',
            'resource_type' => $dataType,
            'metadata' => ['cutoff' => $cutoff->toISOString()],
            'user_id' => 'system',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Consent Management

Patients must give informed consent for data collection and processing. Consent must be tracked, versioned, and revocable:

class ConsentService
{
    public function recordConsent(
        string $patientId,
        string $consentType,
        string $version,
        bool $granted,
        ?string $ipAddress = null
    ): ConsentRecord {
        return ConsentRecord::create([
            'patient_id' => $patientId,
            'consent_type' => $consentType,
            'version' => $version,
            'granted' => $granted,
            'granted_at' => $granted ? now() : null,
            'revoked_at' => !$granted ? now() : null,
            'ip_address' => $ipAddress,
            'metadata' => [
                'user_agent' => request()->userAgent(),
                'consent_form_version' => $version,
            ],
        ]);
    }

    public function hasActiveConsent(string $patientId, string $consentType): bool
    {
        $latest = ConsentRecord::where('patient_id', $patientId)
            ->where('consent_type', $consentType)
            ->latest()
            ->first();

        return $latest && $latest->granted && $latest->revoked_at === null;
    }

    public function revokeConsent(string $patientId, string $consentType): void
    {
        $latest = ConsentRecord::where('patient_id', $patientId)
            ->where('consent_type', $consentType)
            ->where('granted', true)
            ->whereNull('revoked_at')
            ->latest()
            ->first();

        if ($latest) {
            $latest->update(['revoked_at' => now()]);

            DataAnonymisationJob::dispatch($patientId, $consentType)->onQueue('high');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Results

After implementing security-by-design across healthcare data platforms:

Metric Before After
Security audit findings (critical) 7 0
Data access without audit trail ~15% of operations 0%
Unauthorised access attempts detected Unknown 23/month (all blocked and logged)
Compliance audit preparation time 4 weeks 3 days
Data breach incidents N/A (pre-platform) 0
Mean time to detect anomalous access Unknown < 15 minutes

Key Takeaways

  1. Security is architecture, not a feature. It must be built into every layer — authentication, access control, encryption, audit logging, and data retention — from day one.

  2. Log every data access. In healthcare, the question is not "if" an audit will happen but "when." Complete audit trails make the difference between a smooth audit and a compliance crisis.

  3. Encrypt sensitive fields, not just the database. Encryption at rest protects against disk theft. Field-level encryption protects against application-layer breaches and insider threats.

  4. Implement RBAC with conditions. "Doctor" is not a sufficient access level. "Doctor who is assigned to this patient, accessing during working hours, from a known IP" is closer to what healthcare systems require.

  5. Manage consent as a first-class data model. Consent is not a checkbox — it is a versioned, auditable, revocable record that governs what data you are allowed to process.

  6. Plan for data retention from the start. Healthcare regulations specify both minimum and maximum retention periods. Build the retention and purging infrastructure before you have millions of records.


Conclusion

Building healthcare data platforms requires a security mindset that goes beyond typical web application security. Every design decision — from database schema to API design to deployment configuration — must account for the sensitivity of the data and the regulatory requirements that govern it.

The patterns in this article are not theoretical. They come from building systems that handle real patient data under real regulatory scrutiny. Whether you build in Laravel or Node.js, the principles are universal: encrypt by default, log everything, enforce access at every layer, and design for the audit that will inevitably come.


Top comments (0)