I bought a new phone recently and the first thing I did was flash GrapheneOS onto it. Not for any exotic threat model — I just wanted to be rid of the dedicated AI button welded to the side of the device and the pile of preinstalled software I never asked for. GrapheneOS is a hardened Android with verified boot, sandboxed Google Play services, and none of the manufacturer cruft. For everything I do day to day, it behaves like a slightly stricter stock Android.
Then I tried to set up online banking, and my bank’s identification app refused to work.
Säästöpankki, like most Finnish banks, splits its mobile presence into two apps: the main banking app and a separate strong-identification app called Säästöpankki Tunnistus (fi.saastopankki.tunnistus). Tunnistus is the thing that lets you log into government services, sign contracts, and authorize payments — it’s a national-grade electronic identity token. I installed it from Google Play, walked through registration, entered the SMS one-time code on the final screen, and got this:
Havaitsimme, että sovellus on ladattu epävirallisesta lähteestä tai ei ole Säästöpankin tarjoama. Lataa sovellus vain Google Play Store -sovelluskaupasta.
Roughly: we detected that the app was downloaded from an unofficial source or is not provided by Säästöpankki. Only download the app from the Google Play Store. The one place I had downloaded it from was, of course, the Google Play Store. So I pulled the APK apart to find out what was actually happening.
The message that didn’t add up
The first thing worth establishing is that the message is wrong about the thing it claims to be checking. The app was installed by Google Play — installerPackageName reads com.android.vending, and the Play source stamp is present in the package. Any genuine install-source check would pass. So either the check was broken, or the message was describing something other than the install source.
The warning text lives in res/values/strings.xml under the key app_validator_invalid_body. The string id app_validator_invalid_body and the layout it inflates, fragment_app_validator_invalid, both lean hard on the “validator / source” framing. That framing is the misdirection. Resource names are written for humans on the product side; they don’t have to reflect what the code actually decides. To find that out, I had to follow the control flow.
The app is built with R8, so everything is obfuscated — single-letter class and method names, inlined helpers, the usual. I decompiled it two ways: apktool for the resources and smali, and jadx for readable-ish Java. With both in front of me, this stopped being a reading exercise and became a tracing exercise: start at the string, find what inflates the layout that contains it, and walk backwards to the decision.
Following the trail through the decompiler
The warning screen is one branch of a generic error fragment. ErrorScreenFragment switches on an integer layout type and picks which view to inflate:
} else if (i == 18) {
viewInflate = layoutInflater.inflate(R.layout.fragment_app_validator_invalid, viewGroup, false);
} else {
viewInflate = i == 19 ? layoutInflater.inflate(R.layout.fragment_app_validator_error, viewGroup, false) : null;
Layout type 18 is the “unofficial source” warning; 19 is a generic app-check error. The fragment’s proceed button does nothing more interesting than open the Play Store listing, which is consistent with a message that’s trying to send you somewhere to “fix” your install.
Who asks for layout type 18? It’s raised at the very last step of registration — the SMS code entry screen. EnterSMSActivity exposes a callback that hands 18 to the error fragment:
@Override // util.ze.f
public void e2() {
h(ErrorScreenFragment.c2(18, new boolean[0]));
this.presenter.onDestroy();
}
This app uses Moxy, an MVP framework where view commands are dispatched through a generated ViewState. The generated command names survive obfuscation because they’re string literals, and they are the single most useful artifact in the whole APK:
public class b extends ViewCommand {
public b() {
super("appCheckInvalidError", AddToEndSingleStrategy.class);
}
@Override
public void apply(util.ze.f fVar) {
fVar.e2();
}
}
The command that triggers the “unofficial source” screen is named appCheckInvalidError. There’s a sibling named appCheckGenericError that calls the generic variant. Whoever wrote this code knew exactly what the decision was about, and it was not the install source. It was App Check.
The decision itself is server-side. The SMS presenter’s error handler maps backend error codes to view commands:
case -421534606:
if (!strC.equals("OTP-004")) {
getViewState().b();
} else {
getViewState().e2(); // OTP-004 -> "unofficial source" warning
}
break;
OTP-005 maps to the generic app-check error the same way. So the client isn’t deciding anything. It submits the one-time code, the backend replies with OTP-004, and the app dutifully renders that as “you installed this from somewhere shady.”
What makes the backend return OTP-004? The OTP verification call carries an attestation token in a header:
@POST("/codeapp-oidc/authorization/customer/ftn/verify")
Call<VerifyOtpResponseEntity> verifyOtp(@Header("X-Api-Key") String str,
@Header("X-Firebase-AppCheck") String str2,
@Body VerifyOtpRequestEntity verifyOtpRequestEntity,
@Query("state") String str3);
X-Firebase-AppCheck. The app uses Firebase App Check, and the registered provider is Play Integrity (FirebaseAppCheckPlayIntegrityRegistrar is wired in). Firebase App Check asks Play Integrity for a device verdict, packages it into a token, and the bank’s backend validates that token before honoring the OTP.
And that is the whole problem. Play Integrity grades a device against MEETS_DEVICE_INTEGRITY, which requires Google-certified verified boot. GrapheneOS uses its own verified-boot keys, so it fails that specific grade — not because it’s insecure, but because it isn’t on Google’s list. The App Check token comes back without device integrity, the backend rejects it, returns OTP-004, and the app tells me to reinstall from a store I already used. The check is real; the explanation handed to the user is fiction.
Hard block vs server-advisory: four banks compared
To be sure this was a design choice rather than an inherent requirement of Finnish strong identification, I pulled apart three more apps and compared how each one treats device attestation.
| App | Package | Attestation mechanism | GrapheneOS |
|---|---|---|---|
| Säästöpankki Tunnistus | fi.saastopankki.tunnistus | Firebase App Check → Play Integrity, hard OTP-004 block | Fails |
| Säästöpankki Mobiili 4.1.0 | fi.saastopankki | Encap/Signicat SDK, Play Integrity gated server-side (default off) | Works (once enabled) |
| S-mobiili | fi.spankki | Encap/Signicat SDK, same server-gated Play Integrity | Works |
| Nordea | com.nordea.mobiletoken / fi.nordea.mobilebank | Thales RASP + RootBeer local checks, no Google attestation | Works |
The most useful data point is S-mobiili (fi.spankki), which I use and which works on GrapheneOS without complaint. It carries the Encap Security SDK (the same one Signicat ships) and it does call Play Integrity — but the request only fires when a server-controlled mode enum is set to REQUIRED or OPTIONAL, and the default is off:
auh auhVarL = t1gVar.l();
if (auhVarL.boh() == 1 || auhVarL.boh() == 2) { // REQUIRED or OPTIONAL
// request an integrity token
}
Crucially, there is no OTP-004 equivalent anywhere in that codebase — no hardcoded “missing device integrity → block the user” branch on the client. The verdict is one input to a server-side risk decision, and that decision evidently does not hard-reject a clean device just because Google doesn’t recognize its boot keys.
Nordea takes a different route entirely. The token app uses Thales Mobile Protector — a commercial runtime self-protection library focused on local tampering, hooks, and debuggers — and the bank app runs RootBeer to look for su, Magisk, test-keys build tags, and writable system mounts. GrapheneOS passes all of those: release-keys, ro.debuggable=0, ro.secure=1, no su, no Magisk. None of it depends on Google attestation, so none of it trips.
The pattern is clear. Three of four apps treat device posture as a signal to be weighed, or check for local tampering directly. One app treats a single Google-issued grade as a pass/fail gate and converts the fail into a misleading message. Attestation as a server-advisory input degrades gracefully on an unusual-but-secure device. A client-visible hard block tied to Google certification does not.
It’s worth being explicit about what GrapheneOS actually offers here, because “fails Play Integrity” reads like “insecure” and it’s the opposite. GrapheneOS fully supports Android’s hardware key-attestation API — the Keystore-backed attestation that proves boot state from a hardware root of trust, which is a stronger guarantee than the Play Integrity API. A developer who genuinely needs device assurance can verify the attestation certificate chain, require a verifiedBootState of Verified or SelfSigned, and whitelist GrapheneOS’s official verified-boot keys, which the project publishes at https://grapheneos.org/attestation.json (their attestation compatibility guide walks through it). Relying on MEETS_DEVICE_INTEGRITY as the one true gate is a convenience, not a security necessity.
The fix is already written
Here’s the part that turns this from a complaint into something almost optimistic: Säästöpankki has already solved it, in code, today.
The new main app, Säästöpankki Mobiili 4.1.0 (fi.saastopankki), no longer assumes a separate identification app. It ships a full authentication library — dozens of classes under fi.saastopankki.authenticationlibrary — and an onboarding flow whose own copy says the app “includes both banking services and authentication.” It’s built on the same Encap stack as S-mobiili. There is no Firebase App Check, no X-Firebase-AppCheck header, and no OTP-004 branch anywhere in it. In other words, it’s built the way S-mobiili is built — the way that already works on GrapheneOS.
So why didn’t it just work when I tried it? Because the in-app activation entry point is hidden behind a feature flag. The UI only appears when RemotelyEnabledFeatures.TUNNISTUS_2 is enabled, and that flag is delivered through Firebase Remote Config. The packaged defaults in res/xml/remote_config_defaults.xml list the features that are on out of the box:
<key>remotely_enabled_features</key>
<value>["APPOINTMENT_BOOKING","GOOGLE_PAY","OPEN_BANKING","CORPORATE_OPEN_BANKING","CREDIT_LIMIT","CONSUMER_CREDIT","PIN_CODE","CARD_APPLICATION","VARAT"]</value>
TUNNISTUS_2 isn’t in that list. For a new user with no existing profile and the flag defaulting off, the gate evaluates false and the app offers no built-in identification at all — which sends you straight back to the standalone Tunnistus app that doesn’t work. The engineering is finished and shipping inside the APK I already have on my phone. It’s waiting on a single server-side rollout decision.
What this should leave behind
Two things are worth taking from this beyond the specific bank.
The first is about attestation as an architectural dependency. Play Integrity is a reasonable signal, but the moment you wire MEETS_DEVICE_INTEGRITY into a hard pass/fail gate, you’ve outsourced your definition of “trustworthy device” to Google’s certification list — and that list excludes a hardened, verified-boot OS that is more locked down than the stock firmware it replaced. The robust pattern is the one the Encap-based apps use: treat the verdict as one input to a server-side risk decision that can degrade gracefully, and reserve hard cryptographic proof for the hardware attestation API, which actually supports aftermarket operating systems. GrapheneOS users are a useful canary here — the same brittle coupling will eventually bite corporate-managed devices, niche OEMs, and anything else that doesn’t match the reference image.
The second is smaller and more human: when a check fails, tell the user what actually failed. “Installed from an untrusted source” sent me to reinstall an app that was already correctly installed. It cost me an evening with a decompiler to learn the real answer was “your device didn’t pass a remote attestation grade.” Honest error messages don’t help attackers — the failing client already knows it failed — but they save everyone else from chasing the wrong problem. In this case the wrong problem had a right answer sitting in the next app over, one feature flag away from working.