Back to Journal

Securing referral access and implementing S3 uploads

Listen to this articleAI narration
0:00 / 0:00

The medical referral portal had a security problem - anyone with a link could view patient data without authentication. Time to close that gap and fix the clunky file upload flow while we're at it.

The Security Problem

The portal had an "external access" feature: generate a link, send it to someone, they could view the referral without authentication. The URL pattern was /external/ followed by a reference token, then /remisser/ and the referral ID.

The reference was a token stored on the referral document. Anyone with that token could view patient data. No authentication, no expiration, no audit trail.

External Referral Access (Before)InternetAnyone with URLNo auth/external/[ref]/remisser/[id]Public pageAPI Route/api/external/referrals/[id]MongoDBPatient dataSecurity RiskUnauthenticated access to patient referrals

Removing the External Access

Three files needed to go. In the portal app directory, there was the public page component at the external reference path for viewing referrals. There was also the API endpoint that bypassed authentication, and the React Query hook that fetched from this unprotected endpoint.

The page component was fetching referral data through a dedicated API route. That route's GET handler simply looked up the referral by ID and returned it as JSON - no authentication check, just returning the data directly.

The hook was using React Query to fetch from this unprotected endpoint, making requests to the external referrals API path based on the referral ID passed in.

All three files deleted. The external access path is now a 404.

External Referral Access (After)InternetAnyone with URL/external/[ref]/remisser/[id]DELETED404 Not FoundRoute removedAuthenticated Access Only/dashboard/referrals/[id]Requires session authenticationRole-based access control

Simplifying the Admin Link Widget

The admin portal had an "Extern länk" (External link) widget that generated and displayed these now-defunct external links. Instead of removing it entirely, I converted it to a simple "Copy current URL" feature for sharing within the authenticated admin context.

The original component took entity ID, type, and email as props, maintained state for the external link, and had a function to generate external access tokens. It would create a link using the external reference pattern and display it in a read-only input field, with a button to generate the link if one didn't exist yet.

The new version is much simpler. It only takes the entity type as a prop and maintains a copied state flag. The click handler uses the browser's clipboard API to copy the current page URL, shows a success toast message saying "Länk har kopierats till urklipp" (Link has been copied to clipboard), and resets the copied state after two seconds. If copying fails, it shows an error toast.

The UI now shows "Dela länk" (Share link) as the heading, with explanatory text about copying the link to share with colleagues, and a button that toggles between showing "Kopiera länk" (Copy link) and "Kopierad!" (Copied!) with appropriate icons.

No more external link generation. Colleagues can share the dashboard URL, but it requires authentication to access.

Implementing S3 PDF Uploads

The second half of the day: replacing manual URL paste with proper file uploads. Lab technicians were manually uploading PDFs to some location, copying the URL, and pasting it into a text field. Error-prone and inconvenient.

The new flow uses AWS S3 presigned URLs. The pattern comes from another internal project's mailing system:

S3 Presigned URL Upload FlowBrowserAdmin PortalNext.js API/api/uploadAWS S3client bucketRequest upload URLfileName, contentType, sizeGenerate presigned URLPutObjectCommandReturn URL + S3 keyuploadUrl, key, expiresAtDirect upload to S3PUT with presigned URLSave S3 key to referral12345

The S3 Utility Library

I created a new S3 utility module following an existing internal pattern. The module imports the necessary commands from the AWS SDK - GetObjectCommand, HeadObjectCommand, and PutObjectCommand from @aws-sdk/client-s3, plus getSignedUrl from @aws-sdk/s3-request-presigner.

Configuration values come from environment variables: the bucket name, region, access key ID, and secret access key. The module uses a singleton pattern for the S3 client, initializing it lazily on first use with the configured region and credentials.

The createPresignedUploadUrl function takes a key, optional content type, and expiration time defaulting to 300 seconds. It creates a PutObjectCommand with the bucket name, key, and content type, then generates and returns a signed URL using the expiration time.

The createPresignedDownloadUrl function works similarly, using GetObjectCommand instead and defaulting to a one-hour expiration.

Key design decisions include: using a singleton S3 client for connection pooling across requests, setting upload URLs to expire in 5 minutes which is just enough time to complete the upload, setting download URLs to expire in 1 hour for a reasonable viewing session, and enforcing content type at presign time so it can't be changed during upload.

The Upload API Route

The upload API route defines constants for maximum file size at 10 megabytes, presigned URL TTL at 5 minutes, and allowed MIME types restricted to PDF only.

A sanitize filename function cleans input by replacing any character that isn't alphanumeric, a period, hyphen, or underscore with an underscore, then trimming whitespace. If the result is empty, it falls back to a timestamped filename.

The POST handler parses the JSON payload extracting fileName, contentType, size, and referralId. It validates that fileName is a non-empty string, that size doesn't exceed the maximum, and that contentType is in the allowed list. Each validation failure returns an appropriate error message in Swedish with the correct HTTP status code.

After validation, it generates a unique S3 key using the pattern: referrals, then the referral ID or "unspecified", then a timestamp, UUID, and sanitized filename all joined together. This structure provides logical grouping by referral, collision-free naming with the UUID, chronological ordering with the timestamp, and a human-readable filename at the end.

S3 Key Structurereferrals/abc123/1705420800000-550e8400-e29b-41d4-a716-446655440000-lab_report.pdfreferrals/abc123/referralId1705420800000-timestamp550e8400-e29b-41d4-a716-446655440000-UUID (collision prevention)lab_report.pdfsanitized name

The Download API Route

The download API route is simpler. Its POST handler parses the key from the JSON payload, validates that it's a non-empty string, then calls createPresignedDownloadUrl and returns the result. It's a simple key-to-URL translation.

The download API generates fresh presigned URLs on demand - this is important because we store the permanent S3 key in the database, not the temporary presigned URL.

Schema Update

I added s3Key to the referral update schema using Zod. The schema now includes the existing active boolean and url string fields as optional, plus the new s3Key string field also as optional.

Keeping both url and s3Key provides backward compatibility. Old referrals have url from the manual paste workflow, new ones get s3Key from the upload flow. The UI checks for both and displays accordingly.

The Upload Component

I replaced the text input with a proper file upload component called LabAnalysisPdfUpload. It takes referralId, email, ordererEmail, and an optional initialS3Key as props.

The component maintains state for the selected file, an uploading flag, and the current S3 key initialized from the prop if provided.

The file change handler validates that the selected file is a PDF, showing an error toast if not. The upload handler first requests a presigned URL from the API, passing the file name, content type, size, and referral ID. It then uploads directly to S3 using a PUT request with the presigned URL, including the file as the body and setting the Content-Type header. After successful upload, it calls updateReferralAction to save the S3 key to the referral, updates local state, clears the file selection, and shows a success toast.

The download handler requests a fresh presigned download URL from the API and opens it in a new browser tab.

The component's UI shows either the uploaded state with download and replace buttons, or the file input with an upload button when a file is selected.

Upload Component StatesNo FileVälj PDF-fil...File Selectedlab_report.pdf (2.4 MB)Ladda uppUploadingLaddar upp...UploadedPDF uppladdad ✓Ladda nerByt ut

Why Store S3 Keys, Not URLs?

A question came up during code review: why store the S3 key instead of the presigned URL?

What we store in MongoDB is the permanent S3 key like "referrals/123/1705420800000-uuid-report.pdf". We don't store the full presigned URL with all its query parameters.

Presigned URLs are temporary by design. They contain an expiration timestamp, a cryptographic signature, and an AWS credentials reference. If you store a presigned URL in the database, it becomes useless after expiration. The S3 key is permanent - you can always generate a new presigned URL from it.

S3 Key vs Presigned URLS3 Key (What We Store)referrals/123/1705420800000-uuid-report.pdf✓ Never expires✓ Generate URLs on demandPresigned URL (Temporary)https://bucket.s3.region.amazonaws.com/key?X-Amz-Expires=3600&X-Amz-Signature=abc...✗ Expires after 1 hour

Final Architecture

Referral Portal Architecture (After)ExternalUnauthenticatedAdmin UserAuthenticatedAdmin PortalNext.js App RouterSession auth requiredPDF upload UIAPI Routes/api/upload/api/download/api/referrals/api/external/*DELETEDMongoDBReferrals+ s3Key fieldAWS S3PDF StoragePresigned URLsDirect upload(presigned URL)Authenticated flowBlocked access

Summary

Two security improvements shipped today.

First, I removed external referral access. No more unauthenticated viewing of patient data. The external reference path route is gone. The admin "External link" widget now just copies the current authenticated URL.

Second, I implemented S3 PDF uploads. Lab technicians can now upload PDFs directly instead of manually pasting URLs. Presigned URLs keep the file transfer secure and off our servers. S3 keys stored in MongoDB provide permanent reference.

The project required installing two AWS SDK packages: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner.

Three new files were added: the S3 utility module in lib/s3.ts, the upload API route at app/api/upload/route.ts, and the download API route at app/api/download/route.ts.

Several files were modified: the referral schema to add the s3Key field, the referral actions to accept s3Key in form data, the referral detail page to include the new PDF upload component, and the external link manager component simplified to just copy URLs.

Three files were deleted: the external referral page component, the external referrals API route, and the useExternalReferralById hook.

The referral portal is now more secure and more usable. External access is authenticated-only, and file uploads go through proper cloud infrastructure instead of manual URL wrangling.