This document describes MediLink's security architecture — how authentication works, how patient data is protected, and what controls are in place to prevent unauthorized access.
- Authentication
- Authorization & Consent
- Data Encryption
- Rate Limiting
- Audit Logging
- HTTP Security
- Infrastructure Security
MediLink uses JSON Web Tokens (JWT) signed with HS256 for authentication.
| Token Type | Lifetime | Purpose |
|---|---|---|
| Access Token | 2 hours | Authenticates API requests |
| Refresh Token | 7 days | Issues new access tokens without re-login |
Token lifecycle:
- User logs in with email + password
- Server validates credentials and issues access + refresh tokens
- Access token is sent in every API request (
Authorization: Bearer <token>) - When the access token expires, the client uses the refresh token to get a new pair
- On logout, the access token JTI is blacklisted in Redis and the refresh token is revoked in PostgreSQL
Every time a refresh token is used, it is revoked and a new one is issued. This is called rotation.
Reuse detection: If a revoked refresh token is used again (indicating it may have been stolen), the server revokes all refresh tokens for that user. This forces a full re-login on every device.
Normal flow:
Token A → use → revoke A, issue Token B → use → revoke B, issue Token C
Theft detection:
Token A → use → revoke A, issue Token B
Attacker uses stolen Token A again → REVOKE ALL tokens for this user
Physicians and admins can enable TOTP-based two-factor authentication.
How it works:
- Physician calls
POST /auth/totp/setup→ receives QR code and secret - Physician scans QR code with an authenticator app (Google Authenticator, Authy, etc.)
- Physician verifies by entering the 6-digit code →
POST /auth/totp/verify-setup - Server stores encrypted TOTP secret, enables MFA, and returns 10 backup codes
Login with MFA enabled:
- Email + password → server returns a partial access token +
requiresTOTP: true - Client sends TOTP code with the partial token →
POST /auth/login/verify-totp - Server validates the code, blacklists the partial token, issues full tokens
TOTP Lockout: After 5 failed TOTP attempts within 10 minutes, the account is locked for 30 minutes. The lockout is tracked in Redis.
Backup Codes: 10 single-use backup codes are generated at TOTP setup. They are bcrypt-hashed before storage. Each can be used once in place of a TOTP code.
| Control | Detail |
|---|---|
| Hashing | bcrypt with cost factor 12 |
| Minimum length | 8 characters |
| Complexity | Must include uppercase, lowercase, digit, and special character |
| Blocklist | Common passwords are rejected |
| Email check | Password cannot contain the email prefix |
| On change | All refresh tokens are revoked (forces re-login everywhere) |
MediLink has four roles:
| Role | What They Can Do |
|---|---|
| patient | View own health records, manage consent grants, view access log |
| physician | View consented patient records, create/update FHIR resources, prescribe, upload labs |
| admin | All of the above, plus user management, audit logs, physician approval, system admin |
| researcher | Request de-identified data exports |
Roles are checked in middleware before the request reaches the handler. Some endpoints are restricted to specific roles (e.g., only physicians can create prescriptions, only admins can approve physicians).
This is the core security mechanism. Every time a physician reads patient data, the consent middleware checks whether the patient has granted that physician access.
How consent works:
- Patient grants consent to a specific physician → consent starts as pending
- Physician accepts the consent → status changes to active
- Consent can optionally have:
- A scope (which resource types the physician can access)
- An expiration date
- When the physician makes a FHIR read request, the consent middleware:
- Checks Redis cache first (fast path)
- On cache miss, queries PostgreSQL
- Verifies the consent is active, not expired, and covers the requested resource type
- Caches the result in Redis
- Patient can revoke consent at any time → cache is immediately invalidated
Consent enforcement matrix:
| Actor | Search Endpoints | Read by ID |
|---|---|---|
| Patient | Auto-scoped to own data | Can only read own resources (verified by patient_ref) |
| Physician | Must provide patient parameter, consent checked |
Consent checked against resource's patient_ref |
| Admin | Bypass (access logged) | Bypass (access logged) |
What is NOT consent-gated:
- Practitioner and Organization resources (public reference data)
- Write operations (POST, PUT, DELETE) — these are role-gated instead
For emergency situations where a physician needs immediate access without waiting for consent.
Controls:
- Reason must be at least 20 characters (prevents casual use)
- Maximum 3 break-glass accesses per physician per 24 hours (Redis counter)
- Creates a temporary consent that expires in 24 hours
- Patient is notified by email immediately
- Fully logged in the audit trail
- Admin can review all break-glass events via
/admin/audit-logs/break-glass
Patient personally identifiable information is encrypted before storage using AES-256-GCM.
| Field | Encryption | Lookup |
|---|---|---|
| AES-256-GCM encrypted | SHA-256 hash stored separately for lookups | |
| Full Name | AES-256-GCM encrypted | — |
| Phone Number | AES-256-GCM encrypted | — |
| Date of Birth | AES-256-GCM encrypted | — |
| TOTP Secret | AES-256-GCM encrypted | — |
Why two representations for email?
The encrypted email cannot be searched (encryption is non-deterministic — same plaintext produces different ciphertext each time). The SHA-256 hash is deterministic and allows lookup by email without exposing the plaintext in the database.
Key management:
The encryption key is a 256-bit (32-byte) key provided via the ENCRYPTION_KEY environment variable (64 hex characters). In production, this should come from a secrets manager, not from a file.
- PostgreSQL data is stored in Docker volumes
- MinIO objects (uploaded documents) are stored in Docker volumes
- In production, use encrypted volumes and enable PostgreSQL's
sslmode
- Internal service communication is unencrypted (within Docker network)
- In production, enable TLS at the nginx layer for external traffic
MediLink uses Redis-backed rate limiting with different limits per context.
| Limit | Threshold | Window |
|---|---|---|
| Per email | 5 failed attempts | 15 minutes |
| Per IP | 10 failed attempts | 15 minutes |
| TOTP | 5 failed attempts | 10 minutes (then 30-min lockout) |
| Role | Limit | Window |
|---|---|---|
| Patient | 100 requests | 1 minute |
| Physician | 200 requests | 1 minute |
| Admin | 500 requests | 1 minute |
| Auth endpoints | 10 requests | 1 minute |
Maximum 3 emergency access requests per physician per 24 hours.
If Redis is unavailable, the rate limiter allows the request through. This is intentional for a healthcare application — blocking a physician from accessing patient data because of a Redis outage could have worse consequences than allowing unthrottled access temporarily.
Every significant action in MediLink is recorded in an append-only audit log.
| Event | Details Captured |
|---|---|
| Login (success/failure) | User ID, email hash, IP, user agent |
| Logout | User ID, JTI |
| Password change | User ID |
| FHIR resource read | Actor, resource type, resource ID, patient ref |
| FHIR resource create/update/delete | Actor, resource type, resource ID, patient ref |
| Consent grant/accept/decline/revoke | Actor, consent ID, patient ID, provider ID |
| Break-glass access | Physician ID, patient ref, reason |
| Admin actions | Admin ID, target user, action |
| Document upload/processing | Actor, job ID, patient ref |
{
"id": "uuid",
"userId": "actor-uuid",
"userRole": "physician",
"userEmailHash": "sha256-hash",
"resourceType": "Observation",
"resourceId": "resource-uuid",
"patientRef": "Patient/patient-fhir-id",
"action": "read",
"purpose": "treatment",
"success": true,
"statusCode": 200,
"ipAddress": "192.168.1.1",
"userAgent": "Mozilla/5.0...",
"createdAt": "2026-03-12T06:00:00Z"
}Audit entries are batched and written asynchronously to avoid impacting request latency. The audit_logs table has no UPDATE or DELETE operations — entries are immutable once written.
Every response includes:
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options |
SAMEORIGIN |
Prevents clickjacking |
X-Content-Type-Options |
nosniff |
Prevents MIME sniffing |
X-XSS-Protection |
1; mode=block |
XSS filter |
Referrer-Policy |
strict-origin-when-cross-origin |
Controls referrer leakage |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disables sensitive browser APIs |
X-Request-ID |
UUID | Request tracing |
The API uses origin-based CORS allowlisting. Only the configured frontend origins are allowed. Credentials (cookies) are supported.
- All FHIR resources are validated before storage (required fields, valid types, reference integrity)
- All SQL queries use parameterized placeholders (no string concatenation)
- Request body size limits are enforced
- File uploads are validated by content type
- No
dangerouslySetInnerHTMLin any production frontend code - All user input is rendered through React's built-in XSS protection
- API responses use proper Content-Type headers
All services communicate over a Docker bridge network. Only the following ports are exposed to the host:
| Port | Service | Sensitivity |
|---|---|---|
| 8180 | nginx | Public entry point |
| 8580 | Go API | Direct API access |
| 8581 | Asynqmon | Admin tool |
| 5532 | PostgreSQL | Database |
| 6479 | Redis | Cache |
| 9280 | Elasticsearch | Search |
| 9050, 9051 | MinIO | Object storage |
| 9190 | Prometheus | Metrics |
| 3150 | Grafana | Monitoring |
Production recommendation: Restrict all ports except 8180 (nginx) to 127.0.0.1 or remove them entirely.
In the development docker-compose.yml, secrets are inline for convenience. In production:
- Use Docker secrets or a secrets manager (Vault, AWS SSM, etc.)
- Never commit real secrets to version control
- Generate unique values for
JWT_SECRETandENCRYPTION_KEY - Rotate secrets periodically
- Backend containers run on minimal base images (
debian:bookworm-slimfor API/Worker,node:22-alpinefor frontends) - Frontend containers run as non-root user (
nextjs, UID 1001) - No unnecessary packages installed
- Multi-stage builds minimize image size