Treat the client as hostile: keep real secrets on the server, keep long-lived tokens in the Keychain or Keystore, talk HTTPS only, and let the server make every authorization decision. The OWASP Mobile Top 10 (2024) groups these into categories M1-M10.
Credentials and auth (M1, M3): no secrets in the binary, OAuth with PKCE in the system browser, short-lived access tokens, every permission checked on the server
Supply chain (M2): vetted packages, a committed pubspec.lock, upgrades reviewed as small PRs
Input and output (M4): deep links, WebView messages and server data are untrusted; bind SQL parameters, allowlist routes and hosts
Communication (M5): TLS everywhere, no global ATS or cleartext exceptions, pinning where the threat model calls for it
Privacy (M6): collect less, scrub PII from logs and analytics, hide sensitive screens in the app switcher
Binary protection (M7): --obfuscate --split-debug-info raises the cost of reading the app; Play Integrity and App Attest let the server spot tampered builds
Misconfiguration, storage, crypto (M8–M10): no debug flags or backup leaks in release, flutter_secure_storage for tokens, standard AEAD ciphers with random keys
FlutterSenior
Security
Secure storage, certificate pinning, obfuscation, OWASP mobile top 10
Questions closed
0 / 24
- 0Review
- 4Asked often
Questions
Put tokens and keys in flutter_secure_storage, which hands them to the iOS Keychain or to a Keystore-backed cipher on Android, keep the access token in memory, and never store the password. SharedPreferences, unencrypted Hive and plain files are readable from a backup or a rooted phone.
Sort data by harm: tokens, keys and PII go to secure storage or an encrypted database; settings and feature flags can stay in SharedPreferences
Store less: keep the refresh token, not the password; cache only what an offline screen needs
Large data: secure storage is for small values; encrypt a database with SQLCipher or a Hive box with HiveAesCipher, and keep that key in secure storage
Lifecycle: wipe secure storage on logout and on the first launch after a reinstall, because Keychain items outlive the app on iOS
Limits: secure storage protects data at rest; on a rooted or jailbroken device a running app can still be read
Four problems: the tokens sit in a plain preferences file, the access token is printed to the device log, a signing key comes from a predictable generator, and sign-out leaves the refresh token behind, so the session survives logout.
Plain storage: SharedPreferences is an unencrypted XML file or plist, copied into backups and readable on a rooted phone; tokens go to flutter_secure_storage
Logging: debugPrint is not stripped from release builds; it lands in logcat and in crash breadcrumbs, so never log a token, not even part of one
Weak randomness: Random() is a fast PRNG whose output can be predicted; a key that signs requests must come from Random.secure(), or better, from the server
Incomplete sign-out: delete the refresh token too, call the server's revoke endpoint so a copied token stops working, and clear caches that hold user data
First decide which kind of key it is: a publishable identifier can ship, a real secret never does. Anything the app can read at runtime, an attacker with the binary can read too.
Publishable keys: a Firebase config or Maps key is meant to be visible; Maps keys are restricted to your bundle id and signing fingerprint, Firebase data is protected by Security Rules and App Check
Real secrets: a payment provider's secret key, or anything that spends money, goes behind your own backend endpoint authenticated by the user's session, with the server holding the key
Hiding places that do not work: --dart-define values compile into the binary, a .env asset is a plain file in the APK, and strings.xml is one strings command away
Plan for rotation: assume every shipped key leaks eventually, so let the client fetch short-lived credentials from your backend after sign-in
They are OS-level network rules enforced by platform HTTP stacks such as URLSession, OkHttp and WebView: both block plain HTTP by default and allow narrow per-domain exceptions. Dart's own HttpClient, the default under http and Dio, is socket-based and bypasses them.
iOS (ATS): set in Info.plist under NSAppTransportSecurity; blocks HTTP and TLS below 1.2, with per-domain exceptions in NSExceptionDomains
Android: res/xml/network_security_config.xml, referenced from the manifest; cleartext is off by default, <domain-config> allows a staging or corporate CA, and <debug-overrides> accepts a proxy's CA in debug builds only
When you touch them: a legacy endpoint without proper TLS, a corporate root CA the OS does not trust, or a debugging proxy
Which clients they govern: URLSession, OkHttp, WebView, cupertino_http, cronet_http, media players; custom trust anchors and pins here do not reach Dart's HttpClient
Release-only failure with http or Dio: first check <uses-permission android:name="android.permission.INTERNET"/> in android/app/src/main/AndroidManifest.xml; the template adds it only to the debug and profile manifests
Common mistake: allowing cleartext or arbitrary loads globally in production; reviewers and audits flag it
Obfuscation replaces class, method and field names in the release binary with short meaningless tokens, so a decompiled app is hard to read. Enable it with --obfuscate --split-debug-info=<dir>; the second flag writes the symbol map to a separate file.
The symbol map is what makes obfuscated crash reports readable again; lose it and a crash is only raw addresses such as _kDartIsolateSnapshotInstructions+0x1a2b3c, forever.
What it does not do: it does not encrypt assets, prevent decompilation or hide secrets; it only raises the effort
Archive the symbols per release: keep them private, ideally in the CI artifact store, one set per version and platform
Symbolicate by hand: flutter symbolize -i stacktrace.txt -d <dir>/app.android-arm64.symbols restores real names
Symbolicate automatically: upload the symbols file to Crashlytics or Sentry from CI on every release
Do not log secrets, block screen capture on sensitive screens, and keep secrets out of the clipboard. Most real leaks are boring: a token in a crash log, PII in an analytics event, a card screen in the app switcher.
Logs: never print tokens or full request bodies in release, and set scrubbing rules in Crashlytics or Sentry for keys like password, otp, card
Screenshots: Android's WindowManager.FLAG_SECURE blocks capture and the recents thumbnail; iOS has no equivalent, so overlay a blur when the app resigns active
Clipboard: iOS syncs it to the user's Mac and Clipboard.setData has no expiry, so anything sensitive needs a platform channel using UIPasteboard with .localOnly and .expirationDate
Keyboard: obscureText: true with autocorrect: false keeps sensitive input out of the OS dictionaries
Because hiding a tab is not access control: the admin endpoint is still there, and an attacker calls the endpoint, not your widget tree. The JWT payload is just base64, so anyone can edit the role claim and see what the UI does.
What the client may do: decide what to render and skip a request whose exp has passed; nothing more, because a patched binary skips any check the client runs
What the server must do: validate every request itself (signature, iss, aud, exp, the key by kid) and take the caller's identity from the token, never from the request body
Authorize against the resource: GET /orders/42 must check that order 42 belongs to this caller; a userId in the path is routing, not permission (that gap is IDOR)
The clean shape: login returns the capabilities the server will honour, the UI is built from that list, and 403 is a normal state with a real screen
Both are the same bug: outside input is glued into a language (SQL in one case, a file path in the other) instead of being passed as a value. Fix the SQL with bound parameters and the download by never using the server's filename on disk.
SQL: bind, do not concatenate: use ? placeholders with whereArgs; the driver sends the statement and the values separately, so no character in a value can become syntax
Wildcards go in the argument: where: 'title LIKE ?', whereArgs: ['%$q%'], not the whole pattern in the query string
Identifiers cannot be bound: a sort column or table name from the UI goes through an allowlist map, never into the string
Paths: normalise, then verify: a name like ../../Preferences/x.plist writes outside your sandbox; check the joined path with p.isWithin, or store the file under a UUID and keep the display name in a column
It proved only that the OS said yes on this device, right now, in your process. That result never reaches your server and a patched app can flip the boolean, so on its own it is a UI gate, not a credential.
Real protection comes from binding a secret to the biometric instead of branching on true: biometrics unlock something the server already trusts.
Bind data, not a flag: on Android, a Keystore key with setUserAuthenticationRequired(true) used through BiometricPrompt with a CryptoObject; on iOS, a Keychain item created with SecAccessControlCreateWithFlags and .biometryCurrentSet; without a fresh match the refresh token will not decrypt
Invalidate on enrollment change: setInvalidatedByBiometricEnrollment(true) and .biometryCurrentSet destroy the key when a new face or finger is added, so nobody can enrol their own on an unlocked phone
Handle the real errors: no hardware, nothing enrolled, locked out after failures, permission denied; the fallback is a real credential, not a bypass
Re-prompt: for high-value actions and after a background timeout, instead of unlocking the session forever
A WebView runs web content with the trust of your app process, so a misconfigured one can undo secure storage, pinning and the sandbox. Load only pages you control, give the page as little access as possible, and never put sign-in inside it.
Untrusted URLs: if the domain is not yours, open it in the system browser with url_launcher and LaunchMode.externalApplication
JavaScript bridges: a JavaScriptChannel lets page code call your Dart; treat it as a public API, validate every input and allow only known origins
Navigation: block redirects to non-allowlisted hosts in NavigationDelegate; a forgotten redirect is a phishing page inside your app
Cookies and file access: WebViews of one app share cookies, so clear them between flows; keep MIXED_CONTENT_NEVER_ALLOW and setAllowFileAccess(false), because file:// URLs can read your sandbox
Auth belongs in the system browser: ASWebAuthenticationSession / Custom Tabs; Google blocks OAuth in embedded WebViews (disallowed_useragent) and RFC 8252 forbids it, because the host app can read what the user types
A deep link comes from outside your app with parameters an attacker can set, and a plain custom scheme has no owner: any app can register myapp:// too. Claim your links with verified https links and treat everything inside them as hostile input.
Android App Links: android:autoVerify="true" on the intent filter plus /.well-known/assetlinks.json on your domain listing the package name and signing SHA-256; the system verifies it, so no other app can take that link
iOS Universal Links: an apple-app-site-association file on the domain with your Team ID and bundle ID, plus the Associated Domains entitlement
Custom schemes: unverified, so never return an OAuth code or a reset token over one; PKCE makes an intercepted code useless
Allowlist routes: map the path to a known route table, send anything else to a 404 screen, and never open a WebView with a URL taken from the link
A link navigates, it does not act: ?userId=42 only picks a screen; the destination re-checks the session and the server decides what the caller may see
The host check compares string prefixes, so https://api.example.com.evil.io/x.png or https://api.example.com@evil.io/x.png passes it, and any attacker-chosen URL fetched through this Dio receives the user's bearer token.
Why it happens: startsWith compares characters, not origins; in the first URL the host is api.example.com.evil.io, in the second everything before @ is user info and the host is evil.io
Where such URLs come from: chat attachments, avatars, next page links, push payloads and deep links; whoever controls the URL collects tokens in their access log
The fix: parse once and compare uri.scheme and uri.host exactly against an allowlist; for subdomains match host == d || host.endsWith('.$d'), with the leading dot
Defence in depth: Dio ignores baseUrl when given an absolute URL, so fetch third-party URLs (avatars, attachments, next links) with a separate Dio that has no auth interceptor
Catch it next time: a unit test that sends these hostile URLs through the interceptor and asserts there is no Authorization header
Judge a package by how it is maintained and what it can do inside your app, not by its likes; for payments, prefer the vendor's official SDK. Every package runs with your app's permissions, and so does everything it depends on.
Before adding: recent commits, triaged issues, a changelog, a verified publisher, and flutter pub deps for the size of the transitive tree and the licences in it
Native code: an android/ or ios/ folder means Kotlin, Swift or C outside the Dart sandbox, plus Gradle and CocoaPods dependencies the pub graph never shows
Afterwards: commit pubspec.lock and build CI with flutter pub get --enforce-lockfile, let Dependabot or Renovate bring upgrades as small PRs, and run a secret and licence scan in CI
Common mistake: treating dev_dependencies as harmless: code generators run arbitrary code on the CI machine that holds your signing keys
The encrypted blob was backed up and restored, but the key that opens it was not: flutter_secure_storage writes ciphertext into a normal file, while the key lives in the Android Keystore and cannot leave the device.
Android: android:allowBackup defaults to true, so a backup or device transfer copies the file to the new phone without the key, and the first read throws
The fix: exclude the plugin's prefs file in android:dataExtractionRules and android:fullBackupContent, and set AndroidOptions(resetOnError: true) (the default since v10) so a broken install wipes and re-authenticates
iOS: the Keychain outlives the app: items survive uninstall, so keep a first-run flag in SharedPreferences (that file is deleted) and call deleteAll() when it is missing
iOS: Keychain accessibility class: the default cannot be read by a push handler while the screen is locked and looks like a random logout; first_unlock_this_device works after the first unlock
Certificate pinning means the app trusts only your server's certificate or public key, not every certificate the device happens to trust. So even if an attacker installs their own CA on the phone, the connection to your API fails instead of being read.
Pin the public key or the issuing CA, not the leaf certificate: the leaf changes on every renewal, the key usually stays.
Strongest way in Dart: build HttpClient with SecurityContext(withTrustedRoots: false)..setTrustedCertificatesBytes(...), so anything else fails the TLS handshake
Platform clients: an Android <pin-set> in the network security config, or TrustKit on iOS, covers native stacks such as OkHttp and URLSession; Dart's own HttpClient runs its own TLS and ignores those settings
Common mistake: doing the comparison in badCertificateCallback, which never runs for a certificate the device already trusts
Ship a backup pin: for the next key, or the first renewal knocks every installed app offline; make the pin set updatable via signed remote config
Pin only your own hosts: pinning a third-party CDN hands them your uptime
Test the failure path: with pinning on, Charles or Proxyman must fail
No. badCertificateCallback runs only after normal validation has failed, so a proxy certificate signed by a CA the device trusts passes validation and never reaches the pin check: the code pins only certificates that were already rejected.
What the callback is for: deciding whether to accept a certificate the platform rejected; a => true left there from debugging is how release builds end up trusting everything
Fix 1, preferred: SecurityContext(withTrustedRoots: false) with your CA set via setTrustedCertificatesBytes, so any other chain fails in the handshake
Fix 2, a second layer: Dio's IOHttpClientAdapter.validateCertificate sees certificates that passed validation, but it checks the response, so the request has already gone out
Second bug: hashing cert.der pins the whole leaf certificate, which changes at every renewal; pin a public key or the issuing CA, and ship a backup pin
PKCE lets a mobile app do the OAuth code flow safely without a client secret. The app proves it is the same app that started the login, so a stolen authorization code is useless to anyone else.
A mobile app is a public client: any secret compiled into it can be extracted, so client_secret proves nothing. PKCE replaces it with a one-time proof per login.
The flow: the app generates a random code_verifier and sends code_challenge = base64url(SHA256(verifier)) with code_challenge_method=S256; after sign-in it exchanges the returned code together with the original verifier, and the IdP issues tokens only if the hash matches
What it defends: an attacker who hijacks the redirect gets the code but not the verifier, so the exchange fails
Why required: OAuth 2.1 makes PKCE mandatory and drops the implicit grant; RFC 8252 says native apps must use it
System browser, never WebView: ASWebAuthenticationSession on iOS, Custom Tabs on Android, and a claimed universal link rather than a custom scheme for the redirect
Use a short-lived access token (minutes) for requests and a longer-lived refresh token to get new ones, and issue a brand-new refresh token on every refresh. A stolen token then expires fast, and reuse of an old refresh token is detectable.
Where they live: access token in memory, refresh token in flutter_secure_storage; never in SharedPreferences or in a URL
Rotation: each refresh returns a new pair and the server invalidates the old refresh token; if a stolen old token is used, the real device gets a 401 on its next refresh, which is your signal to revoke the whole family
Client side: one refresh in flight at a time: concurrent 401s await the same Future, the new pair is saved before the original request is replayed; two parallel refreshes would trip reuse detection against yourself
When refresh itself returns 401: logout, password change or revocation: wipe storage and send the user to login
Device binding: DPoP, mTLS or an attested key make a stolen refresh token useless on another device
Each request that gets a 401 starts its own refresh. When several requests fail together, they all send the same refresh token; the server rotates it on the first call, treats the second as reuse of a spent token and revokes the whole session.
Why it happens: the access token expires for every request at once, so after a background pause the first screen's three or four parallel calls get 401 together, and each onError calls tokens.refresh() with the refresh token still in storage
Why the server signs the user out: with rotation a refresh token is single-use; a second use looks exactly like a stolen token being replayed, so reuse detection revokes the token family (working as designed, against your own client)
The fix: keep one Future for the refresh in flight; every 401 awaits it and retries with the new access token, and the field is cleared on completion so the next expiry starts a fresh refresh
**QueuedInterceptor alone is not enough**: it runs onError calls one after another, which removes the reuse, but each queued 401 still rotates the pair again; compare the token the request was sent with to the current one and just retry if it has changed
Catch it next time: a test with a fake HttpClientAdapter that fires three requests with an expired token and asserts exactly one call to /auth/refresh
Local checks (su binaries, Magisk or Cydia paths, test-keys builds, emulator traits) are easy to write and just as easy to hook, because Frida rewrites their result inside your process. The durable control is hardware-backed attestation that your server verifies: Play Integrity or App Attest.
Play Integrity: the replacement for SafetyNet: the server mints a nonce, the app requests a token, and the server decrypts and verifies it, never the app
Verdicts to name: appRecognitionVerdict PLAY_RECOGNIZED for an unmodified binary, deviceRecognitionVerdict MEETS_BASIC / DEVICE / STRONG_INTEGRITY, appLicensingVerdict for a Play install
App Attest: DCAppAttestService makes a key in the Secure Enclave and attests it against your server's challenge; later calls carry an assertion with a rising counter, which stops replay
Risk signal, not a gate: step up auth or lower limits instead of blocking; custom ROMs, CI emulators and devices without Play services fail routinely
Local checks stay as telemetry: RootBeer-style path checks raise the attacker's cost but never decide; Dart only fetches the token and attaches it
A key anyone can compute is not a key, and a fixed IV makes identical documents produce identical ciphertext; together this is obfuscation, not encryption. Use a random key kept in secure storage, a fresh random nonce per message, and an authenticated mode.
The key: an email is public and has almost no entropy; use 256 random bits from a CSPRNG, or a slow KDF such as Argon2id over a real secret with a per-user salt
The nonce: reuse leaks the XOR of two plaintexts and lets an AES-GCM attacker forge tags; generate 96 fresh random bits per message and store them beside the ciphertext, the nonce is not secret
Authentication: raw CBC or CTR lets an attacker flip plaintext bits; use an AEAD such as AES-GCM and reject the message when the tag fails, before parsing anything
Randomness: Random() is a fast PRNG; only Random.secure() reaches the platform CSPRNG
Where the key lives: a random data key encrypts the files and itself sits in Keychain/Keystore via flutter_secure_storage; a local database wants SQLCipher, not a hand-built container
Platform disk encryption only protects a phone that is off: after the first unlock since boot the keys stay in memory, and everything outside flutter_secure_storage sits under exactly that default. The caches were readable because the phone was on.
iOS file protection: app files default to CompleteUntilFirstUserAuthentication; NSFileProtectionComplete ties the key to the lock screen instead, at the cost that background fetch and push handlers cannot read those files while locked
Android: File-Based Encryption unlocks credential-encrypted storage after the first unlock post-boot; on a rooted device everything is readable regardless
The backup path: android:allowBackup defaults to true, so exclude sensitive files in dataExtractionRules; on iOS keep derived data in Caches or flag it excluded from iCloud backup
The copies people forget: image and HTTP caches, WebView cookies, exported PDFs in tmp, logs in Documents; logout must purge all of it, not just tokens
Encrypt above the platform: SQLCipher for the database and a Keystore-wrapped data key for files; only a key gated on the user protects a phone that is on
The device is granting the premium tier, and the device runs the attacker's code. The grant has to be made on your server from a receipt the store signed, and the client only displays what the server decided.
The flow: buy through in_app_purchase and POST PurchaseDetails.verificationData.serverVerificationData to your backend with the user's session
Server-side verification: the App Store Server API or Google Play Developer API checks the store's signature, and you reject a receipt already bound to another account
Grant, then complete: grant server-side, return the entitlement, then call completePurchase; an unacknowledged Android purchase is refunded after three days
What the client must not do: treat PurchaseStatus.purchased as the grant or keep isPremium locally; cache the server's entitlement with a short TTL
The states everyone forgets: refunds, cancellations, grace periods, restore on a new device: wire the stores' server notifications into the same service