Last updated: June 2026 — eIDAS compliance updates added
The following updates have been made to bring the AES system into compliance with eIDAS Article 26 (Sole Control) and the Human Medicines Regulations 2012:
acr claim) instead of a backend service account token, satisfying the "sole control" requirementpython-requests)GET /api/prescriptions/{id}/evidence returns a signed JSON document containing all evidence pillarsiss claimThis document describes the architecture of the Advanced Electronic Signature (AES) service at aes.veripath.co.uk and its relationship to the GP Booking App and Dental Booking App.
AES is a signing service — it provides cryptographic signing of prescriptions using PAdES-B-LTA compliant digital signatures. It does not store patient data. Patient records remain in the respective booking applications.
aes.veripath.co.uk
├── /admin* Admin dashboard, pharmacies, certificates, audit
├── /prescribe* Standalone prescription form (dev/internal use)
├── /api/sign Server-to-server signing API (for booking apps)
├── /api/pharmacies Pharmacy search/lookup API
├── /api/prescriptions/{id}/pdf PDF download with chain-of-custody
├── /api/prescriptions/{id}/evidence Evidence Summary (eIDAS compliance)
└── /auth/* Keycloak OAuth2 (SSO)
Internal microservices (Docker):
| Service | Role | Internal URL |
|---|---|---|
| aes-portal | FastAPI web app + API gateway | port 8004 |
| aes-pki | Private PKI — certificate lifecycle | port 8001 |
| aes-tsa | RFC 3161 Timestamp Authority | port 8002 |
| aes-signer | PDF signing orchestrator (pyHanko) | port 8003 |
| aes-softshsm | SoftHSM2 key storage | internal |
The system now supports multi-realm JWT verification. Each clinic operates in its own Keycloak realm (e.g. veripath, test-client), and the AES Portal dynamically resolves the correct realm from the token's iss claim:
| Client | Type | Purpose |
|---|---|---|
aes-portal |
Confidential (OAuth2) | Admin UI login for AES developers |
gp-booking-app |
Confidential (OAuth2) | Booking app user login (per-realm) |
aes-signer-sa |
Service account | Fallback booking app → AES API calls |
aes-pki-sa |
Service account | Internal PKI operations |
aes-tsa-sa |
Service account | Internal timestamp operations |
iss claim extracted: https://auth.veripath.co.uk/realms/{realm}GET /realms/{realm}/protocol/openid-connect/certsn and e parameters (RS256)sub, preferred_username, acr, auth_timeA separate Keycloak realm (veripath, test-client, etc.) is used for client organisations. Each organisation operates in its own realm:
veripath realm test-client realm
└── Users └── Users
└── Clients └── Clients
└── JWKS (unique) └── JWKS (unique)
Booking App AES Portal Keycloak
│ │ │
│ 1. User logs in (MFA) │ │
│ ────────────────────────────────────────────────────────▶│
│◀─────────────────────────────────────────────────────────│
│ JWT: { user, org_id, │
│ acr (MFA level), │
│ auth_time } │
│ │ │
│ 2. Clinician views │ │
│ patient record │ │
│ (in booking app DB) │ │
│ │ │
│ 3. Clicks "Issue │ │
│ Prescription" │ │
│ ✓ Confirms consent │ │
│ statement │ │
│ │ │
│ 4. POST /api/sign │ │
│ Authorization: │ │
│ Bearer <USER JWT> │ ← clinician's own token │
│ X-Forwarded-For: │ ← real client IP │
│ User-Agent: <browser>│ ← real browser UA │
│ { │ │
│ org_id, │ │
│ clinician_cn, │ │
│ patient: {...}, │ │
│ medication: {...}, │ │
│ pharmacy_id, │ │
│ consent_text, │ │
│ consent_timestamp │ │
│ } │ │
│─────────────────────────▶│ │
│ │ 5. Verify JWT (any realm) │
│ │ 6. Extract ISS → realm │
│ │ 7. Fetch realm JWKS │
│ │ 8. Verify RSA signature │
│ │ 9. Store actor evidence: │
│ │ sub, acr, auth_time, │
│ │ IP, User-Agent │
│ │ 10. Call signer → TSA → PKI │
│ │ 11. Compute PDF SHA-256 │
│ │ 12. Log to audit chain │
│◀─────────────────────────│ │
│ { success, │ │
│ prescription_id, │ │
│ signed_pdf_path, │ │
│ pdf_sha256 } │ │
│ │ │
│ 13. Store evidence in │ │
│ medication_details │ │
│ (JSONB in booking │ │
│ app PostgreSQL) │ │
If the user JWT is not available or is rejected (e.g. the clinic's realm has no matching JWKS), the system falls back to a service account token (aes-signer-sa). This preserves functionality but captures reduced evidence (no acr, no user identity claims).
| Data | Stored In | Notes |
|---|---|---|
| Patients | Booking App DB | Source of truth. Shared across apps. |
| Clinical notes | Booking App DB | Linked to patient records |
| Prescriptions (metadata + evidence) | Booking App DB (JSONB) | medication_details contains actor evidence, consent, SHA-256 |
| AES signing records | AES portal.db | prescription_id, clinician, pharmacy, status, PDF path |
| Signed PDFs | AES filesystem (/data/prescriptions/) |
PAdES-B-LTA compliant, contains patient data embedded in PDF |
| Pharmacies | AES portal.db | Shared across all organisations (NHS directory) |
| Medications | AES portal.db | dm+d VMP data (24,511 entries) |
| Audit logs | AES filesystem (/data/audit/) |
Tamper-evident hash chain, includes org_id |
| Certificates | AES PKI + filesystem (/data/certs/) |
HSM-backed keys |
portal_prescriptions:
- id (PK)
- prescription_id (unique, from booking app)
- org_id (from JWT claim)
- clinician_cn
- clinician_gmc
- medication_name
- medication_details (JSONB) ← now contains actor evidence
├── strength, form, quantity, dosage
├── actor_sub — Keycloak user UUID
├── actor_username — preferred_username claim
├── actor_acr — Auth Context Ref (MFA level)
├── actor_auth_time — MFA authentication timestamp
├── actor_ip — Real client IP address
├── actor_user_agent — Browser User-Agent string
├── consent_text — Signed consent statement
├── consent_timestamp — When consent was given
└── pdf_sha256 — SHA-256 hash of signed PDF
- pharmacy_id (FK → pharmacies)
- status
- signed_pdf_path
- delivery_status
- signed_at
- created_at
Patient data is NOT stored in this table. It exists only in the signed PDF and the booking app's database.
Server-to-server endpoint for booking applications to create signed prescriptions with full evidence capture.
Authentication: Bearer JWT (user's own Keycloak token, with MFA acr claim)
Headers:
Authorization: Bearer <user-jwt> — Clinician's own OIDC access tokenX-Forwarded-For: <client-ip> — Real IP address of the signing clinicianUser-Agent: <browser-ua> — Real browser User-Agent stringRequest:
{
"prescription_id": "RX-20260519-0001",
"org_id": "maple-surgery",
"clinician_cn": "Dr Jane Smith",
"patient": {
"name": "John Smith",
"nhs_number": "1234567890",
"dob": "1980-01-15",
"address": "123 High Street"
},
"medication": {
"name": "Amoxicillin 500mg capsules",
"dosage": "One three times daily",
"quantity": "21",
"form": "Capsules",
"strength": "500mg"
},
"pharmacy_id": 123,
"instructions": "Take with food",
"consent_text": "I confirm I wish to electronically sign this prescription as the prescriber...",
"consent_timestamp": "2026-06-19T10:00:00Z"
}
Response:
{
"success": true,
"prescription_id": "RX-20260519-0001",
"signed_pdf_path": "/data/prescriptions/RX-20260519-0001.pdf",
"signed_at": "2026-06-19T10:00:00+00:00",
"delivery_status": "pending",
"pdf_sha256": "944196eb..."
}
Download a signed prescription PDF with chain-of-custody logging.
Authentication: Bearer JWT (user or service account)
Response Headers:
X-Content-SHA256: <sha256-hash> — SHA-256 hash of the PDF for tamper verificationAudit Logging: Each download is logged to the hash-chained audit trail with actor identity, IP, and User-Agent.
Return the signed Evidence Summary for a prescription — the complete evidence package for eIDAS compliance.
Authentication: Bearer JWT (user or service account)
Response:
{
"evidence_type": "PAdES-B-LTA Evidence Summary",
"generated_at": "2026-06-19T10:00:00+00:00",
"generated_by": "doctor",
"prescription": {
"prescription_id": "RX-20260519-0001",
"clinician_name": "Dr Jane Smith",
"patient_name": "John Smith",
"medication_name": "Amoxicillin 500mg capsules",
"status": "signed",
"signed_at": "2026-06-19T10:00:00+00:00"
},
"signed_pdf": {
"path": "/data/prescriptions/RX-20260519-0001.pdf",
"exists": true,
"sha256": "944196eb..."
},
"actor_evidence": {
"keycloak_sub": "8d860cb4-c4d7-45c8-a9ff-f0062bd258e2",
"username": "doctor",
"authentication_context_ref": "1",
"auth_time": 1781863823,
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)..."
},
"consent": {
"text": "I confirm I wish to electronically sign this prescription as the prescriber...",
"timestamp": "2026-06-19T10:00:00Z"
}
}
Pharmacy search/lookup. Supports ?q= and ?search= parameters with relevance-ordered results (prefix name matches first, then name contains, then address contains).
| Pillar | Component | Captured |
|---|---|---|
| Cryptographic | PDF SHA-256 hash, PAdES-B-LTA X.509 certs, TSA token | ✅ |
| Identity | Keycloak sub UUID, preferred_username, acr (MFA level), auth_time |
✅ |
| Forensic | Client IP address (X-Forwarded-For), browser User-Agent |
✅ |
| Intent | Signed consent text + timestamp | ✅ |
| Temporal | TSA-signed timestamp in PDF, signed_at in evidence |
✅ |
| Chain-of-Custody | Hash-chained audit log (audit_chain.jsonl) per download event |
✅ |
| Tamper Evidence | SHA-256 hash in X-Content-SHA256 header on every PDF download |
✅ |
The prescription creation form captures all legally required data points under the Human Medicines Regulations 2012:
| Block | Fields |
|---|---|
| Prescriber Information | Full name, qualification type (GP/Dentist/Nurse), GMC/GDC/NMC number, practice name, practice address, contact email/phone |
| Patient Information | Full name, full residential address, NHS number, date of birth, exact age (if under 12) |
| Medication Specifications | Generic name (SNOMED CT / dm+d), form, strength, dose & frequency instructions, total quantity |
| Controlled Drugs | Schedule warning, quantity in words AND figures, 28-day validity notice |
| Legal Validation | AES digital signature (PAdES-B-LTA), date of issue, 6-month validity |
| Consent | Explicit consent checkbox with statement under Human Medicines Regulations 2012 |
┌──────────────┐ TLS 1.2 ┌──────────────┐
│ Internet │────────────────▶│ nginx │
└──────────────┘ └──────┬───────┘
│
┌─────────▼─────────┐
│ aes-portal:8004 │
│ (FastAPI) │
└──┬──────┬──────┬───┘
│ │ │
┌──────────────────┘ │ └──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ aes-pki │ │ aes-signer │ │ aes-tsa │
│ port 8001 │ │ port 8003 │ │ port 8002 │
└──────┬───────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ aes-softshsm │
│ (HSM keys) │
└──────────────┘
For the GP Booking App (and Dental Booking App) to use AES with full eIDAS compliance:
| Requirement | Implementation |
|---|---|
| Keycloak — User JWT | Pass the user's OIDC access token from request.session['oidc_access_token'] instead of using a backend service account |
| Consent capture | Show a consent checkbox when "Issue Immediately" is toggled; require it before submission |
| IP/UA forwarding | Extract HTTP_X_FORWARDED_FOR and HTTP_USER_AGENT from the Django request and pass them through the signing chain as HTTP headers |
| Secure fallback | If the user JWT fails (e.g. different realm), fall back to the service account token |
| Store evidence in PostgreSQL | medication_details JSONB stores aes_prescription_id, aes_signed_pdf_path, aes_pdf_sha256, aes_consent_text, aes_consent_timestamp, aes_client_ip, aes_client_ua |
| Download buttons | "Download Signed PDF" (with SHA-256 header) and "Download Evidence Summary" (JSON) on the prescription detail page |
The AES Portal maintains two audit files in /data/audit/:
audit.jsonl — Human-readable event log (JSON lines format)audit_chain.jsonl — Hash-chained log where each entry contains prev_hash linking to the preceding entry, forming a SHA-256 hash chain| Event | Data Captured |
|---|---|
prescription_signed |
Actor sub, acr, IP, prescription_id, clinician, patient, medication |
prescription_pdf_downloaded |
Actor, IP, User-Agent, prescription_id, PDF SHA-256 |
prescription_delivered |
Delivery method, prescription_id |
delivery_failed |
Error message, prescription_id |
delivery_retry |
Admin action, prescription_id |
system_startup |
Service lifecycle |
The chain integrity can be verified via GET /admin/audit in the AES Portal Web UI, which reports:
| Requirement | How Met |
|---|---|
| Uniquely linked to signatory | Keycloak sub UUID stored with every signing; user JWT verified via JWKS |
| Capable of identifying signatory | preferred_username + sub + browser IP/UA captured |
| Under sole control | Clinician's own OIDC token (password + MFA), not a shared service account |
| Consent / intent | Explicit consent checkbox with timestamp stored in evidence |
| Tamper detection | PAdES-B-LTA PDF + SHA-256 hash in header + hash chain audit log |
| Audit trail | Immutable hash-chained audit log with chain-of-custody on all evidence access |
| Requirement | How Met |
|---|---|
| Data segregation by org | org_id on every record; app enforces org-scoped queries |
| Patient data minimisation | AES stores no patient data — only prescription references |
| Audit trail | All signing events logged with org_id, clinician ID, timestamp |
| Encryption at rest | Signed PDFs stored on encrypted filesystem |
| Encryption in transit | TLS 1.2+ for all external; internal network isolated |
| Access control | Keycloak SSO with role-based access per organisation |
| eIDAS evidence | Full actor evidence + consent + forensic data captured and downloadable |