devShakib

The Encryption You Actually Need (and the Kind You're Wasting Time On)

Encryption for Flutter and Firebase apps: what actually matters. TLS in transit, encryption at rest, app layer and E2E encryption, key management, and password hashing.

A client once asked me to add "military-grade encryption" to their app, and asked it like they were ordering a bigger padlock off a shelf. I asked what threat they were defending against. Long pause. What they actually had was a login form posting over plain HTTP and a Firestore database anyone with the API key could read top to bottom. They wanted a heavier lock for the front door while the back wall was missing entirely.

That conversation repeats itself, in different costumes, on nearly every project I touch. Encryption has this gravity to it — it feels like the serious, grown-up part of security, so people reach for it first and reach for the wrong kind. The truth is that a normal app has maybe three or four encryption decisions that actually matter, and most of the "encryption work" I watch teams pour weeks into is theater. This post is me sorting the real from the performative, from six years of shipping Flutter and Firebase apps out of Dubai and cleaning up after a fair number of my own bad calls.

The through-line is simple: encryption is a tool for one narrow problem, and you should reach for it only after you know exactly which problem you have. Everything below is organized around matching the right control to the right threat, because a mismatched control is worse than no control — it costs you real hours and buys you a false sense of safety that stops you fixing the actual hole.

Encryption is not a feature you sprinkle on

The first mistake is treating encryption as a checkbox: "encrypt the data" as if data were one thing sitting in one place. It isn't. Your data lives in at least three states, and each has a completely different threat model:

Encryption protects the first two. It does almost nothing for the third, which is where most real breaches actually land — a leaked API key, an over-permissive database rule, a stolen session token, a compromised laptop with a valid login. I have watched teams AES-encrypt a single column while leaving the query endpoint that serves that column wide open. That is welding the safe shut and taping the combination to the door.

So before you write a line of crypto, ask the only question that matters: who are you defending this data from, and what can they already reach? Encryption answers exactly one shape of problem — "someone got the raw bytes but not the key." If your attacker gets the key too, because it ships inside your app or lives one table over from the data, you have encrypted nothing. You have added latency, a decrypt path to maintain, and a false sense of safety that stops people from fixing the real hole.

A quick way to keep yourself honest: write the threat model as a single sentence before you pick a control. "An attacker who intercepts network traffic on public Wi-Fi" points at transport encryption. "An attacker who exfiltrates a database backup" points at application-layer encryption. "An attacker who steals a valid session token" points at access control and token hygiene — not encryption at all. If you can't finish that sentence, you're not ready to write the code.

In transit: the part your platform already solved for you

Here is the good news nobody celebrates because it's boring: transport encryption is a solved problem, and you are almost certainly already getting it for free.

If your app talks to Firebase, any modern cloud API, or your own server behind a normal reverse proxy, traffic runs over TLS 1.2 or 1.3. The handshake, the cipher negotiation, the certificate validation — the platform does all of it. You do not implement TLS. You do not roll your own. Anyone selling you a "custom encrypted channel" layered on top of HTTPS is selling you a slower HTTPS with extra bugs.

What you actually have to get right in transit is small and specific:

In Dart, the anti-pattern looks like this — and if you see it in a review, treat it as a security bug, not a style nit:

// DO NOT DO THIS. This disables TLS validation entirely.final client = HttpClient()  ..badCertificateCallback = (cert, host, port) => true;

The correct version is: do nothing. Use the default client, let the platform validate the chain, and spend the saved hours on the layers that aren't automatic. If you genuinely need pinning, do it deliberately with a known fingerprint and a rotation plan — not by disabling the check and calling it "flexible."

// Deliberate pinning: compare against a known cert fingerprint.final client = HttpClient()  ..badCertificateCallback = (cert, host, port) {    final fingerprint = sha256.convert(cert.der).toString();    return fingerprint == kPinnedFingerprint; // fail closed on mismatch  };

The detail that saves you: pin to the intermediate CA's public key, not the leaf certificate, and ship at least one backup pin. Leaf certificates rotate every few months; pin the leaf and you sign up for an app-store release every renewal, with a hard outage if you miss the window. If you can't commit to a rotation runbook, don't pin — an unmaintained pin is a self-inflicted denial of service waiting for a cert expiry.

Encryption at rest: what your database does vs. what you think it does

This is where the biggest gap between belief and reality lives.

When someone says "our database is encrypted at rest," what is almost always true is full-disk, storage-level encryption: the cloud provider encrypts the physical volumes. Firestore, Cloud SQL, S3, RDS — all encrypt at rest by default, and you did nothing to earn it. It's on the marketing page because it's free to the provider, not because it's doing heavy lifting for you.

Now, what does that actually protect you from? Exactly one scenario: someone physically walks a disk out of the datacenter. That's it. Storage-level encryption is transparent to any authenticated query. The database decrypts on read automatically, every time. So if an attacker gets a valid connection, a leaked service account, or a Firestore rule that quietly says allow read: if true, encryption at rest does precisely nothing. The data flows out in cleartext because, to the query engine, it is cleartext.

I say this bluntly because I have sat in meetings where "the data is encrypted at rest" was offered, straight-faced, as the answer to "what happens if someone gets access to the database." Those are two unrelated sentences. One is about stolen hardware in a building I will never visit. The other is about access control, which is entirely my problem.

So the at-rest decisions that actually move your security posture are not encryption decisions at all:

// Firestore rules: match the operation to the door you think you locked.match /users/{uid} {  // 'read' covers both get AND list. If you only reasoned about single-doc  // 'get', a list query still slips through unless the rule holds here too.  allow read: if request.auth != null && request.auth.uid == uid;}

Storage-level encryption is table stakes you already have. Treat it as a checkbox that ships pre-checked, and move on to the things that actually stop an attacker.

Application-layer encryption, and the key-management tax nobody mentions

Sometimes storage-level encryption genuinely isn't enough. The classic case: you're storing something so sensitive that you don't want even your own database admins — or a leaked backup, or a subpoena served on your cloud provider — to see it in cleartext. Medical notes. Government IDs. A password manager's vault. National ID numbers, which in this region people hand over for the smallest transactions.

For that, you do application-layer encryption: your code encrypts the value with a key you control before it ever reaches the database, and decrypts it after reading. Now a leaked database dump is genuinely useless without the key.

This is real, legitimate encryption work. And here's the part nobody tells you when they cheerfully suggest it in a planning meeting: the encryption is the easy 10%. Key management is the other 90%, and it never ends.

The moment you hold a key, you own a set of questions that don't have tidy answers:

The pragmatic answer for most teams is: don't hold raw keys yourself. Use a managed KMS — Google Cloud KMS, AWS KMS — with the envelope-encryption pattern. You generate a random data key per record, encrypt the data with it, then ask KMS to encrypt that data key. You store the encrypted data key next to the ciphertext. The master key never leaves the KMS boundary.

// Envelope encryption, conceptually:// 1. Generate a random data key (DEK) locally.final dek = generateRandomKey();               // e.g. 256-bit// 2. Encrypt the sensitive value with the DEK (authenticated encryption).final ciphertext = aesGcmEncrypt(plaintext, dek);// 3. Ask KMS to encrypt the DEK with the master key (KEK).final wrappedDek = await kms.encrypt(dek);      // master key stays in KMS// 4. Store ciphertext + wrappedDek together. Discard the raw DEK.await db.write({'data': ciphertext, 'key': wrappedDek});

A couple of details that matter and are easy to miss. Use authenticated encryption — AES-GCM or equivalent — not raw AES-CBC. Authenticated modes give you an integrity tag, so tampered ciphertext fails to decrypt instead of silently returning garbage. And never reuse a nonce with the same key under GCM; a fresh random nonce per encryption is not optional, it's a correctness requirement.

The whole point of the pattern is that your database holds only ciphertext and wrapped keys, and the actual master key never touches your servers or your backups. Rotation becomes a KMS operation instead of a full re-encryption of your dataset, and access to decrypt becomes an IAM decision you can log and revoke.

Do this when the sensitivity of the data justifies the operational tax. Do not do it because it feels thorough. Application-layer encryption on low-sensitivity data buys you almost nothing and costs you real capability: you can't index, filter, or search an encrypted blob, so every query that used to be a where clause becomes "load everything, decrypt in memory, filter in code." That's a performance cliff and a scaling problem you signed up for voluntarily — on top of a key-management burden you now carry for the entire life of the product. Before you encrypt a column, ask whether you'll ever need to query on it. If the answer is yes, you're choosing between searchability and confidentiality, and that's a design conversation, not a code change.

End-to-end encryption: when it's worth the product pain

End-to-end encryption (E2EE) is the strongest promise you can make: only the sender and receiver can read the content — not even you, the operator. The keys live on the users' devices. Your servers relay ciphertext they cannot decrypt. This is what Signal does, and what WhatsApp does for message content.

It is also, from a product standpoint, expensive in ways that have nothing to do with the cryptography:

E2EE earns its cost when not being able to read user data is the actual product promise — private messaging, a health app where confidentiality is the entire pitch, anything where "trust us" isn't good enough and "we literally cannot see it" is the differentiator you're selling.

It is not worth it for a typical SaaS app that needs to render, search, and support the data server-side. Bolting E2EE onto a product that fundamentally needs server-side access to content is how you end up with fake E2EE — a marketing claim with a backdoor that quietly defeats the entire point. Here's my hard opinion, and I'll defend it: if you can reset a user's account and still show them their old content afterward, you do not have end-to-end encryption, and you are lying if you say you do. Pick one. Either you can read it or you can't. There is no "encrypted, but recoverable by us" that is also E2EE — that's just server-side encryption with better copywriting.

Hashing, tokens, and the difference people keep blurring

A surprising amount of "encryption" confusion is really people using the wrong word for the wrong tool. Three of these get blurred constantly, and mixing them up causes real, shippable bugs.

Encryption is reversible. You encrypt so you can decrypt later with a key. If you never need the original value back, you probably don't want encryption at all — you want a hash.

Hashing is one-way. You cannot get the input back from the output. This is what you use for passwords: you never decrypt a password, you hash the login attempt and compare it to the stored hash. And the algorithm choice matters enormously:

Tokens are for identity, not secrecy. A session token or JWT proves who someone is. A JWT is signed, not encrypted — by default anyone holding it can base64-decode the payload and read every claim in it. So never stuff a secret into a JWT thinking it's hidden. It is sitting there in plain text behind a trivial decode. If you truly need confidential claims, that's a JWE (encrypted JWT), which is a different and less common tool — but the honest answer is usually "keep the secret server-side and put only an opaque reference in the token."

The single most common real-world bug in this whole area: storing passwords with a fast hash, or worse, "encrypting" them so they can be turned back into the original. If your system is capable of producing a user's original password — for a "we'll email you your password" feature, say — you have built a liability, not a convenience. Passwords get hashed: one way, slowly, with a per-user salt. Full stop.

A field guide to when you're just performing crypto

Over the years I've built a rough smell test for whether a piece of encryption work is real or ritual. If several of these are true, you're probably performing crypto rather than doing security:

The inverse is a short, unglamorous list of what actually earns its keep: TLS everywhere (which you already have), correct access rules on your data, slow salted password hashes, secrets kept out of the client and out of the logs, and application-layer or E2E encryption only where a named threat and a real product promise justify the operational cost. None of it photographs well for a pitch deck. All of it is what actually keeps user data where it belongs.

Key takeaways