Control PWA uploads and downloads on the server, where they can be enforced. Authorise every transfer, validate uploaded bytes, keep them outside the application’s origin and serve them back from a separate origin with Content-Disposition: attachment, X-Content-Type-Options: nosniff and Cache-Control: no-store. Keep private files out of the service worker’s cache, and clear them on logout in every context where the app runs, including iOS home-screen web apps, which keep storage separate from Safari. A hidden button or a file picker filter enforces nothing. Test any native wrapper as its own boundary.
Map browser, content and wrapper responsibilities
Draw the transfer path before changing any setting: file selection, upload request, validation, storage, retrieval and local use. Name the origin and account at each step. Keep private content apart from the public assets that let the application shell work offline.
| Component | Responsibility | Boundary to test |
|---|---|---|
| Browser interface | Explain file rules and show transfer state | Skipping the interface must not skip server checks |
| Upload service | Authorise the operation and validate incoming bytes | Untrusted metadata, incomplete transfers, duplicate requests |
| Private object storage | Restrict access to approved content | Direct object URLs and cross-account access |
| Download service | Check the current user’s right to the object | Expired sessions and changed ownership |
| Service worker and browser storage | Apply the caching and retention policy | Logout, account switching, old cached responses |
| Native wrapper, if present | Mediate its own file and navigation interfaces | Data handed to the OS or another app |
Know where storage lives
Installed web apps do not all share storage with the browser.
- iOS and iPadOS. A web app added to the Home Screen keeps its own cookies and website data, separate from Safari. A sign-out in Safari does not sign the user out of the home-screen app, and cached files in one are invisible to the other. Test logout, account switching and cache clearing in each.
- Android. A PWA installed from Chrome runs on Chrome’s engine and shares cookies and storage with Chrome for the same origin. A sign-out in one affects the other.
- Native wrappers. A WebView in a store app has its own storage, file chooser and download handling, which the wrapper code controls.
Otukan protects the native wrapper of a web app packaged as an Android or iOS store app, and can close native functions such as file access to the page when the bridge between web content and native code is tampered with. Web content running in a browser or a home-screen install is secured with the web controls and server checks in this guide. For product context, see progressive web app protection; navigation boundaries are covered on the PWA deep-link guide.
Put file controls where they can be enforced
Accept uploads safely
- Define the file types, size limits and quotas the business needs, and enforce them on the server. Record which roles may upload what.
- Authorise the destination before accepting content. Store each file under a server-generated identifier; never use the supplied filename as a path.
- Validate the extension against an allowlist and check the content with a suitable parser. OWASP warns that the
Content-Typerequest header is set by the client and can be spoofed. - Store uploads outside the web root, ideally on separate storage. Hold unreviewed files in a restricted state until the checks your threat model requires pass.
- Release the object for retrieval only once validation and ownership are recorded. Delete rejected and abandoned uploads on a defined schedule.
Use purpose-built fixtures for adverse testing: a disallowed extension, mismatched content type, a truncated file, an HTML or SVG file carrying script, and an attempt to write to another account’s destination.
Serve downloads from a separate origin
An uploaded HTML or SVG file served from your app’s origin runs with that origin’s cookies, storage and service worker. Serve user content from a different registrable domain reserved for it, so a malicious file cannot reach the application’s session.
Set these headers on every user-content response:
| Header | Purpose |
|---|---|
Content-Disposition: attachment; filename*=UTF-8''… | The browser saves the file instead of rendering it (RFC 6266) |
X-Content-Type-Options: nosniff | The browser uses the declared type instead of guessing from the bytes |
Content-Type set by the server from validation | Never echo the type the uploader supplied |
Cache-Control: no-store | Shared and browser HTTP caches do not keep private files (RFC 9111) |
Content-Security-Policy: sandbox | Defence in depth if a file is rendered anyway |
For each download, check the account, the object and the current access decision, on the normal route and any direct one. If you use signed or delegated URLs, keep their lifetime short and remember that signing a URL does not make it single-use or revocable.
Keep private files out of offline storage
Cache-Control: no-store governs HTTP caches, not the service worker. The Cache API does not honour HTTP caching headers: anything your code puts there stays until your code deletes it. Exclude private transfer responses from generic caching routes. Where offline documents are a requirement, design account separation, retention and logout deletion explicitly, and test that the bytes are gone, not just that the screen is clear. On logout, delete Cache API entries and IndexedDB records for the account; a Clear-Site-Data response header can help where the browser supports it.
A file the user saves to the device leaves the application’s control. Tell users when a download does that.
Verify distribution, network paths and updates
Run the same synthetic transfer suite in the browser, the home-screen install and the signed wrapper on each operating system. Record app revision, runtime version, OS, origin, policy and account role with every result.
| Exercise | Expected decision |
|---|---|
| Valid authorised upload and download | Content reaches only the intended account |
| Account switched during an interrupted upload | Ownership is not silently reassigned |
| Another account requests the object directly | The server denies access whatever the interface shows |
| Uploaded HTML or SVG opened from its URL | It downloads, or renders without access to the app’s origin |
| Logout, then offline reopening | Private content follows the retention policy in each context |
| Old service worker against new server rules | Stale clients cannot relax server rules |
| Redirect, retry or network interruption | Destination and authorisation stay within the design |
Repeat installation, update, sign-in and the sensitive document workflow after any change to protection, caching or distribution. Run legitimate transfers alongside adverse cases so a blanket failure is not mistaken for protection.
For recovery, stop serving affected content, keep a redacted diagnostic record and fix the failing boundary. If stale assets are involved, test the service worker update and cache clean-up with real clients; deploying new JavaScript does not remove old copies at once.
- Server validation holds against direct requests and interface bypass.
- User content is served from its own origin with attachment, nosniff and no-store headers.
- Browser, home-screen and wrapper results are recorded separately per OS.
- Logout and account switching remove stored bytes in each context.
- Recovery is verified with both old and updated clients.
Broader integration responsibilities are covered on the integration overview.
Further reading
- File Upload Cheat Sheet — OWASP
- RFC 6266: Use of the Content-Disposition Header Field in HTTP — IETF
- RFC 9111: HTTP Caching — IETF
- X-Content-Type-Options — MDN Web Docs
- Cache — MDN Web Docs