Secure File Upload on Android — Chunked, Resumable, Encrypted Uploads for Banking & Enterprise Apps
“Upload a file” sounds like one of the most basic things an app can do, and the tutorials treat it that way: build a MultipartBody.Part, hand it to Retrofit, done. Then you ship it to a banking app where users upload a photo of a $5,000 check over hotel Wi-Fi, and you discover everything the tutorials left out. The upload fails 40% through and starts over from zero. The 8MB image takes 90 seconds and the user backgrounds the app halfway. The check image — containing a routing number, account number, and signature — travels and rests on your server as plaintext. The progress bar lies because it’s reporting bytes-handed-to-OkHttp, not bytes-confirmed-by-server.
Secure, reliable file upload for a banking or enterprise app is a genuinely hard problem with several distinct sub-problems: handling large files on unreliable networks (chunking and resumption), protecting sensitive content (client-side encryption and integrity), choosing the upload path (direct-to-storage vs. proxied), reporting honest progress that survives process death, and verifying on the server that what arrived is what was sent. This post follows a single file — a check image in a banking app — through every stage of a production-grade secure upload, continuing the banking/enterprise frame from the Security cluster.
Stage 0: What Are We Actually Protecting?
Same discipline as the Security foundation post: before building, name the threats.
For a check image (or KYC document, signed contract, medical record — any sensitive uploaded file), the threats:
- Interception in transit — someone on the network reading the file. Defeated by TLS (mandatory) plus, for the highest-stakes files, application-layer encryption so even a TLS compromise doesn’t expose plaintext.
- Exposure at rest on the server — the file sitting in object storage as plaintext, readable by anyone who breaches the storage bucket or an insider with bucket access. Defeated by encryption-at-rest (storage-level, and ideally application-level so the storage provider never sees plaintext).
- Tampering — the file modified in transit or at rest. Defeated by integrity verification (hash check on both ends).
- Unauthorized upload — someone uploading to your storage without going through your app’s auth. Defeated by signed, scoped, short-lived upload URLs or authenticated proxy uploads.
- Metadata leakage — the file’s EXIF data (GPS coordinates of where the check photo was taken, device model, timestamp) leaking PII. Defeated by stripping metadata client-side before upload.
Not every file needs every defense. A profile photo doesn’t need application-layer encryption; a check image does. The discipline is matching defense to sensitivity, the same proportionality from the Security foundation post.
Stage 1: The User Picks the File
The journey starts with file selection. On modern Android, use the Photo Picker (for images) or Storage Access Framework (for arbitrary files) — not legacy storage permissions.
@Composable
fun CheckUploadScreen(viewModel: CheckUploadViewModel) {
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
// PickVisualMedia is the modern Photo Picker — no storage permission needed,
// privacy-friendly (user picks exactly one item, app sees only that)
) { uri: Uri? ->
uri?.let { viewModel.onCheckImageSelected(it) }
}
Button(onClick = {
photoPicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}) {
Text(“Photograph your check”)
}
}
The Photo Picker is the right default in 2026: no READ_EXTERNAL_STORAGE permission, the user picks exactly the file they intend to share, and your app gets a scoped URI it can read once. For a banking app, this matters — requesting broad storage access for a check upload is both a privacy red flag and a Play Store policy concern.
The result is a content URI (content://...), not a file path. You read it via ContentResolver. Don’t assume you can get a File object — on modern Android you often can’t, and shouldn’t try.
Stage 2: Pre-Processing — Strip, Resize, Hash
Before the file leaves the device, three operations matter for a sensitive image:
Strip metadata. A photo taken with the camera carries EXIF data: GPS coordinates, timestamp, device model, sometimes more. For a check photo, the GPS coordinates reveal where the user banks from — their home, their office. Strip it.
suspend fun stripExifAndResize(context: Context, sourceUri: Uri): ByteArray =
withContext(Dispatchers.IO) {
// Decode the image
val original = context.contentResolver.openInputStream(sourceUri).use { input ->
BitmapFactory.decodeStream(input)
} ?: throw IOException(“Could not decode image”)
// Resize if larger than needed — a check doesn’t need to be 12 megapixels
val resized = if (original.width > 2048 || original.height > 2048) {
val scale = 2048f / maxOf(original.width, original.height)
Bitmap.createScaledBitmap(
original,
(original.width * scale).toInt(),
(original.height * scale).toInt(),
true
)
} else original
// Re-encode as JPEG — this process DROPS all EXIF metadata as a side effect
// The new JPEG has no GPS, no original timestamp, no device info
ByteArrayOutputStream().use { output ->
resized.compress(Bitmap.CompressFormat.JPEG, 85, output)
output.toByteArray()
}
}
Re-encoding through Bitmap.compress conveniently strips all EXIF as a side effect — the new JPEG is built fresh without the original metadata. (If you need to preserve some EXIF, like orientation, read and re-apply only the safe fields explicitly with ExifInterface.)
Hash the content. Compute a SHA-256 of the processed bytes. This hash serves two purposes: integrity verification (the server recomputes and compares) and deduplication (if the same file was uploaded before, skip re-upload).
fun sha256(data: ByteArray): String {
val digest = MessageDigest.getInstance(“SHA-256”)
return digest.digest(data).joinToString(“”) { “%02x”.format(it) }
}
Resize. Covered above — a check or document doesn’t need full camera resolution. Smaller files upload faster, cost less storage, and process faster server-side. Resize to the minimum resolution that preserves the necessary detail (text legibility for OCR, in the check case).
Stage 3: Client-Side Encryption (For the Highest-Stakes Files)
TLS protects the file in transit; storage-level encryption protects it at rest from casual access. But for the most sensitive files, you want the server to never see plaintext at all — end-to-end encryption where only the user (and authorized recipients) can decrypt.
The pattern: generate a random data encryption key (DEK), encrypt the file with it, encrypt the DEK with a key the user controls (or a key escrowed in your KMS for recovery), upload the ciphertext.
data class EncryptedPayload(
val ciphertext: ByteArray,
val iv: ByteArray,
val encryptedDek: ByteArray, // The DEK, wrapped by a master key
val authTag: ByteArray // GCM authentication tag (often appended to ciphertext)
)
fun encryptFile(plaintext: ByteArray, masterKey: SecretKey): EncryptedPayload {
// 1. Generate a one-time data encryption key for THIS file
val dek = KeyGenerator.getInstance(“AES”).apply { init(256) }.generateKey()
// 2. Encrypt the file with the DEK using AES-GCM (authenticated encryption)
val fileCipher = Cipher.getInstance(“AES/GCM/NoPadding”)
fileCipher.init(Cipher.ENCRYPT_MODE, dek)
val ciphertext = fileCipher.doFinal(plaintext)
val iv = fileCipher.iv // GCM IV, needed for decryption
// 3. Wrap (encrypt) the DEK with the master key
// The master key lives in Android Keystore (hardware-backed, from Security post)
val keyCipher = Cipher.getInstance(“AES/GCM/NoPadding”)
keyCipher.init(Cipher.ENCRYPT_MODE, masterKey)
val encryptedDek = keyCipher.doFinal(dek.encoded)
return EncryptedPayload(ciphertext, iv, encryptedDek, /* authTag in ciphertext */ ByteArray(0))
}
This is “envelope encryption” — the standard pattern. Why a per-file DEK instead of encrypting directly with the master key: it lets you re-key (rotate the master) without re-encrypting every file (just re-wrap the DEKs), and it limits the blast radius if a single DEK is somehow compromised.
The honest caveat: true end-to-end encryption where the server cannot decrypt creates real product constraints. The server can’t run OCR on the check, can’t do fraud analysis on the image, can’t show it in a web dashboard without the user’s key. Most banking apps use encryption-at-rest with server-managed keys (the server can decrypt for legitimate processing, the storage provider cannot) rather than true E2E, because the business needs to process the check. Reserve true E2E for files that genuinely never need server-side processing. The product decision drives the crypto architecture.
Stage 4: Choosing the Upload Path
Two architectures, each with trade-offs:
Path A: Proxied Upload (Through Your Server)
Client → Your API server → Object storage (S3/GCS)
Pros: your server sees every byte, can validate, scan for malware, enforce business rules, log for audit. Full control. Simpler auth (the client just talks to your authenticated API).
Cons: your server bandwidth and compute are in the upload path. Every byte goes through your infrastructure twice (client→server, server→storage). At scale this is expensive and a bottleneck.
Path B: Direct-to-Storage with Signed URLs
Client → (asks your server for a signed URL) → uploads directly to object storage
Pros: your server is out of the data path. The client uploads straight to S3/GCS using a short-lived signed URL. Scales beautifully; your server only issues URLs.
Cons: your server doesn’t see the bytes, so validation/scanning happens after upload (triggered by a storage event), not before. More moving parts.
The signed URL flow:
// 1. Client asks YOUR server for an upload URL, authenticated
suspend fun requestUploadUrl(fileHash: String, fileSize: Long): UploadTicket {
return api.requestUpload(
UploadRequest(
contentHash = fileHash,
sizeBytes = fileSize,
contentType = “image/jpeg”,
purpose = “check_deposit”
)
)
// Server returns: a signed URL valid for ~5 minutes, scoped to exactly
// this content type and size, writable only to one specific object key
}
data class UploadTicket(
val uploadUrl: String, // Pre-signed PUT URL
val objectKey: String, // Where it lands in storage
val expiresAt: Long, // Short-lived
val requiredHeaders: Map<String, String> // e.g., x-amz-server-side-encryption
)
The signed URL is the security boundary: it’s scoped (only this object key), time-limited (5 minutes), size-limited (rejects oversized uploads), and content-type-limited. An attacker who intercepts it can only do exactly what it permits, for five minutes. Combined with the server enforcing x-amz-server-side-encryption headers, the file lands encrypted at rest.
For banking, the typical choice is Path B (signed URLs) with a post-upload validation step: a storage event triggers a server-side function that decrypts (if server-managed keys), runs malware scanning, validates the image is actually a check, recomputes the hash, and only then marks the upload “confirmed.” The client uploads efficiently; the server validates asynchronously.
Stage 5: Chunked, Resumable Upload (The Part Tutorials Skip)
Here’s the problem the basic tutorials never address: an 8MB upload over hotel Wi-Fi takes 90 seconds, and mobile connections drop. If your upload is a single PUT and the connection dies at 80%, you start over from zero. The user tries three times, fails three times, and abandons the deposit.
The fix is resumable uploads: split the file into chunks, upload them independently, track which succeeded, resume from where you left off after a failure.
Both S3 (multipart upload) and GCS (resumable upload) support this natively, as does the tus open protocol. The conceptual flow:
class ResumableUploader(
private val api: UploadApi,
private val chunkSize: Int = 5 * 1024 * 1024 // 5MB chunks
) {
suspend fun upload(
data: ByteArray,
uploadTicket: UploadTicket,
onProgress: (Float) -> Unit
): UploadResult {
val totalChunks = ceil(data.size.toDouble() / chunkSize).toInt()
val uploadedChunks = loadResumeState(uploadTicket.objectKey)
// ✅ Check local storage for which chunks already succeeded
// (persisted across app restarts and process death)
for (chunkIndex in 0 until totalChunks) {
if (chunkIndex in uploadedChunks) {
// ✅ Already uploaded in a previous attempt — skip
onProgress((chunkIndex + 1).toFloat() / totalChunks)
continue
}
val start = chunkIndex * chunkSize
val end = minOf(start + chunkSize, data.size)
val chunk = data.copyOfRange(start, end)
// Upload this chunk with retry
retryWithBackoff(maxAttempts = 3) {
api.uploadChunk(
url = uploadTicket.uploadUrl,
chunkIndex = chunkIndex,
totalChunks = totalChunks,
chunkData = chunk,
contentRange = “bytes $start-${end - 1}/${data.size}”
)
}
// ✅ Persist that this chunk succeeded, so a crash mid-upload
// doesn’t lose progress
markChunkUploaded(uploadTicket.objectKey, chunkIndex)
onProgress((chunkIndex + 1).toFloat() / totalChunks)
}
// Tell storage to assemble the chunks into the final object
return api.completeUpload(uploadTicket.objectKey, totalChunks)
}
}
The two things that make this resilient:
1. Per-chunk retry with backoff. A single chunk failure retries just that chunk, not the whole file. Three failures of one chunk after backoff is a real failure; one transient failure is invisible to the user.
2. Persisted resume state. Which chunks succeeded is written to local storage (Room or DataStore) after each chunk. If the app is killed mid-upload (process death, OEM battery killer, user backgrounding), the next attempt resumes from the last confirmed chunk instead of restarting. For an 8MB file that died at 80%, the user re-uploads 20%, not 100%.
Stage 6: Surviving Process Death — Upload via WorkManager
A long upload shouldn’t live in a ViewModel or a coroutine tied to the screen. If the user backgrounds the app or the screen is destroyed, that coroutine dies. The right home for a file upload is WorkManager — it survives process death, app backgrounding, and even reboots (with the right configuration).
class CheckUploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val objectKey = inputData.getString(“object_key”) ?: return Result.failure()
val localFilePath = inputData.getString(“local_path”) ?: return Result.failure()
return try {
// Set this as a foreground service for long uploads (Android requires
// long-running work to be foreground, with a notification)
setForeground(createForegroundInfo(progress = 0f))
val uploader = ResumableUploader(api)
val data = readLocalFile(localFilePath)
val ticket = loadUploadTicket(objectKey)
uploader.upload(data, ticket) { progress ->
// Update the notification with progress
setProgressAsync(workDataOf(“progress” to progress))
setForegroundAsync(createForegroundInfo(progress))
}
// Clean up local temp file
deleteLocalFile(localFilePath)
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) {
Result.retry() // WorkManager re-runs with backoff
} else {
Result.failure()
}
}
}
}
// Enqueue with constraints
val uploadWork = OneTimeWorkRequestBuilder<CheckUploadWorker>()
.setInputData(workDataOf(“object_key” to objectKey, “local_path” to tempPath))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
“check_upload_$objectKey”,
ExistingWorkPolicy.KEEP,
uploadWork
)
Why WorkManager rather than a foreground service you manage yourself: WorkManager handles the retry policy, the constraint satisfaction (wait for network), the persistence across reboots, and the backoff. You write the upload logic; WorkManager handles the lifecycle. For a banking check deposit, the user expects “I submitted it, it’ll go through” — WorkManager delivers that even if they close the app immediately after.
Combine with the resumable-chunk state from Stage 5: WorkManager restarts the worker after process death, the worker resumes from the last confirmed chunk. The two mechanisms together give you genuinely robust uploads.
Note the OEM caveat from the OEM Fragmentation post: on aggressive OEMs, even WorkManager can be delayed or killed. For a banking upload, this means the deposit might be delayed rather than immediate on a hostile device — communicate “pending” status clearly rather than claiming instant success.
Stage 7: Honest Progress Reporting
The progress bar most apps show is a lie. It reports bytes handed to OkHttp’s buffer, which fills almost instantly — so the bar jumps to 95% in a second and then hangs while the actual network transfer happens. Users learn to distrust it.
Real progress requires hooking into the actual network write. With OkHttp, wrap the request body:
class ProgressRequestBody(
private val delegate: RequestBody,
private val onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit
) : RequestBody() {
override fun contentType() = delegate.contentType()
override fun contentLength() = delegate.contentLength()
override fun writeTo(sink: BufferedSink) {
val total = contentLength()
var written = 0L
val countingSink = object : ForwardingSink(sink) {
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
written += byteCount
onProgress(written, total)
// ✅ Fires as bytes are ACTUALLY written to the network socket
}
}
val bufferedSink = countingSink.buffer()
delegate.writeTo(bufferedSink)
bufferedSink.flush()
}
}
This reports bytes as they hit the socket, not as they enter OkHttp’s buffer. Combined with chunked upload, you get genuinely accurate progress: each completed chunk is confirmed by the server, so the chunk-level progress is real, and within a chunk, the byte-level progress reflects actual network transfer.
For the user, honest progress matters more than fast progress. A bar that accurately shows “slow but moving” keeps them waiting; a bar that hits 95% and hangs makes them force-quit and retry, wasting the upload.
Stage 8: Server-Side Verification
The file arrived. Before treating the upload as successful, the server verifies:
1. Hash match. The server recomputes the SHA-256 of the received bytes and compares to the hash the client declared. Mismatch means corruption or tampering — reject.
2. Content validation. Is it actually a JPEG? Is it actually a check (for the check-deposit case, an ML model or OCR validation)? Reject files that aren’t what they claim to be — a critical defense against someone uploading malware disguised as an image.
3. Malware scanning. Run the file through a scanner. Even an “image” can carry exploits targeting image-parsing libraries.
4. Size and format limits. Enforce them server-side regardless of what the client claimed. The client’s declared size and the signed URL’s limits are hints; the server enforces.
The principle from the Security foundation post applies again: server-side enforcement is the real security boundary. The client strips EXIF, hashes, encrypts, and chunks — but a tampered client could skip all of that. The server re-validates everything that matters because the server is the part the attacker can’t modify.
Only after server-side verification passes does the upload transition to “confirmed” and the client UI show success. Reporting success before server confirmation is the bug that lets corrupted or malicious uploads through.
The Full Picture
┌────────────────────────────────────────────────────────────────────┐
│ Stage │ What happens │
├────────────────────────┼───────────────────────────────────────────┤
│ 0. Threat model │ Match defenses to file sensitivity │
│ 1. Pick file │ Photo Picker / SAF, scoped content URI │
│ 2. Pre-process │ Strip EXIF, resize, SHA-256 hash │
│ 3. Encrypt (if needed) │ Envelope encryption, per-file DEK │
│ 4. Choose path │ Signed URL (scale) vs proxied (control) │
│ 5. Chunked upload │ Resumable, per-chunk retry, persisted │
│ 6. WorkManager │ Survives process death, backgrounding │
│ 7. Honest progress │ ProgressRequestBody on actual socket write│
│ 8. Server verify │ Hash, content, malware, limits — the bar │
└────────────────────────────────────────────────────────────────────┘
Not every file needs all eight stages. A profile photo: pick, resize, simple upload, done. A check image or signed contract: the full pipeline. The art is knowing which stages a given file actually requires — over-engineering a profile-photo upload with envelope encryption and chunking is as wrong as under-engineering a check upload with a naive single PUT.
Pitfalls Worth Calling Out
Reading the entire file into memory. For a 50MB enterprise document, contentResolver.openInputStream(uri).readBytes() allocates 50MB on the heap and risks OOM on low-RAM devices. Stream in chunks; don’t materialize the whole file unless it’s small and you’ve checked the size first.
Trusting the client-declared content type. The client says image/jpeg; an attacker’s modified client says image/jpeg for a malware payload. The server must sniff the actual content, not trust the declared type.
Long-lived signed URLs. A signed URL valid for 24 hours is a 24-hour window for an interceptor. Make them as short as the upload realistically needs — 5-15 minutes, with the resumable upload session refreshing as needed.
Progress that reports buffer-fill, not socket-write. The lying-progress-bar problem. Use ProgressRequestBody wrapping the actual sink.
Forgetting metadata stripping. The check photo’s GPS EXIF reveals where the user banks from. Strip it. This is a real PII leak that’s trivially avoidable.
Upload coroutine tied to the screen. User backgrounds the app, upload dies. Use WorkManager for anything that should survive the screen’s lifecycle.
Claiming success before server confirmation. The bytes reached storage but failed validation. If you showed success on storage-arrival, you’ve told the user their check deposited when it was actually rejected. Wait for server confirmation.
No deduplication. The user retries a failed upload; you store the same file twice. The content hash from Stage 2 lets the server recognize “I already have this exact file” and skip re-storage.
When to Use the Full Pipeline vs. Keep It Simple
The honest framing, consistent with the rest of this blog:
Full pipeline (all 8 stages): banking check deposits, KYC document uploads, signed legal contracts, medical records, anything regulated or genuinely sensitive and large. The engineering investment is justified by the stakes.
Middle ground (resumable + WorkManager + honest progress, skip heavy encryption): general document upload in a productivity app, large media in a social app, anything where reliability matters but the content isn’t a regulatory concern.
Keep it simple (pick, resize, single multipart PUT): profile photos, small attachments, anything small and non-sensitive where a failed upload is a minor annoyance the user can easily retry.
Don’t cargo-cult the full pipeline onto every upload. The chunking, encryption, and WorkManager machinery has real cost (complexity, maintenance, edge cases). Apply it where the file size or sensitivity justifies it. A recipe app uploading a dish photo does not need envelope encryption.
Closing
“Upload a file” is one of those deceptively-simple operations that reveals its depth the moment you ship it to a banking app with real users on real mobile networks uploading real sensitive documents. The pipeline — strip and hash, encrypt if warranted, signed-URL or proxy, chunked and resumable, WorkManager-hosted, honest progress, server-verified — is what separates “works in the demo” from “works for a user on hotel Wi-Fi depositing a paycheck.”
The recurring theme across this Security cluster holds here too: the client does the work to make uploads efficient and to protect content, but the server is the security boundary. The server re-validates the hash, re-checks the content, enforces the limits, because the server is the part an attacker can’t modify. Client-side encryption and integrity raise the bar and protect users from incidental exposure; server-side verification is what actually keeps the system sound.
That’s three posts in the Security cluster now: the foundation (threat model, storage, pinning, biometrics), root and tampering detection, and secure file upload. Next: the biometric authentication deep-dive that the foundation post only introduced — CryptoObject patterns, fallback strategies, and the accessibility considerations that matter when biometrics are the gate to someone’s money.
Happy coding!
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.