In a heart-failure care app I worked on, patients record short neck videos at home for a clinical assessment. Video files are large, mobile networks are flaky, and the data is sensitive. Routing those uploads through the API server would have made it a bottleneck and a liability. Instead, the app uploads straight to cloud storage using short-lived signed URLs, and the API only ever handles small JSON requests.
Why not upload through the API
- Memory and timeouts. Buffering a large video per request ties up the server and hits request-size and timeout limits (serverless platforms cap request bodies at a few MB).
- Cost and latency. Every byte crosses the network twice.
- Blast radius. The API never needs broad write access to the bucket.
The flow
1. app ──POST /uploads/initiate──► API (auth, checks, returns signed PUT URL)
2. app ──PUT video──────────────► storage (direct, URL valid ~15 min)
3. app ──POST /uploads/complete──► API (verify the object exists, create the record)
4. worker ─poll pending─► API ─► process ─► upload result ─► notify the patientThe API stays in charge of who may upload what, without ever touching the bytes.
Signing a URL with no SDK
Google Cloud Storage accepts URLs signed with a service account's private key. You don't need the storage SDK for this; Node's crypto does it:
import { createSign, randomUUID } from 'node:crypto';
function signedUploadUrl(bucket: string, path: string, contentType: string) {
const { client_email, private_key } = serviceAccount(); // from a secret, never the repo
const expires = Math.floor(Date.now() / 1000) + 15 * 60;
const stringToSign = `PUT\n\n${contentType}\n${expires}\n/${bucket}/${path}`;
const signature = createSign('RSA-SHA256').update(stringToSign).sign(private_key, 'base64');
return (
`https://storage.googleapis.com/${bucket}/${path}` +
`?GoogleAccessId=${encodeURIComponent(client_email)}` +
`&Expires=${expires}&Signature=${encodeURIComponent(signature)}`
);
}
// Names are server-generated, so clients can't overwrite or guess paths.
const objectName = `${randomUUID()}.mp4`;The signature covers the method, content type, expiry and exact path. A URL for one file can't be reused to write another, and it dies after 15 minutes.
The details that made it reliable
- Server-generated names. The client never chooses the object path; the API generates a UUID name inside a per-patient, per-assessment folder.
- Verify before you record. On "complete", the API checks the object really exists before creating anything in the database. A cheap way: a signed GET with
Range: bytes=0-0, which returns one byte instead of the whole video. - Reads are signed too. Clinicians get short-lived read URLs on demand; nothing in the bucket is public.
- Idempotent deletes. Deleting treats
404as success, so retries and clean-up jobs never fail on something already gone. - Lock the internal endpoints properly. The processing worker talks to internal endpoints protected by a shared secret, checked in a guard, so unauthenticated requests are rejected before any multipart body is buffered into memory. Checking inside the handler would let anyone make the server allocate memory first.
- Set CORS on the bucket for the app's origins and the
PUTmethod, or browsers will block the direct upload.
What it bought us
The API handles small, fast requests; uploads scale with the storage provider, not the server; and the security model is easy to explain: the server signs narrowly scoped, short-lived permissions, and the client spends them.
The same pattern works on S3 (presigned URLs) and Azure (SAS tokens).