{"id":80875,"date":"2026-07-28T11:54:37","date_gmt":"2026-07-28T06:24:37","guid":{"rendered":"https:\/\/www.tothenew.com\/blog\/?p=80875"},"modified":"2026-07-30T15:55:08","modified_gmt":"2026-07-30T10:25:08","slug":"react-native-app-security-part-4-protecting-your-apis-with-app-attestation","status":"publish","type":"post","link":"https:\/\/www.tothenew.com\/blog\/react-native-app-security-part-4-protecting-your-apis-with-app-attestation\/","title":{"rendered":"React Native App Security (Part 4): Protecting Your APIs with App Attestation"},"content":{"rendered":"<h2>A Secure App Still Has an Exposed Backend<\/h2>\n<p>In\u00a0Part 3, we covered freeRASP \u2014 continuous runtime protection that watches your app for hooking, tampering, and integrity violations for the entire session, not just at startup.<\/p>\n<p>But here\u2019s the uncomfortable part: none of that protects your backend.<\/p>\n<p>Your app\u2019s JS bundle can be extracted from the APK or IPA and reverse-engineered. Even with Hermes compiling it to bytecode, a motivated attacker can recover your API endpoints, request shapes, and headers. Once they have that, they don\u2019t need your app at all \u2014 they can write a script that talks to your API directly.<\/p>\n<p>JailMonkey and freeRASP both protect the device your app runs on. Neither one stops a request that never goes through your app in the first place.<\/p>\n<p>We\u2019ll look at:<\/p>\n<ul>\n<li>What problem App Check actually solves<\/li>\n<li>How attestation tokens work, conceptually<\/li>\n<li>Setting it up in a React Native app and enforcing it on your backend<\/li>\n<li>Why this is a different layer of defense than SSL pinning, not a replacement for it<\/li>\n<\/ul>\n<h2>The Problem: Your API Doesn\u2019t Know Who\u2019s Calling It<\/h2>\n<p>A backend endpoint, by default, just answers whoever asks. If someone extracts your <strong>\/register<\/strong> or <strong>\/login<\/strong> URL from your bundle, nothing stops them from scripting requests directly against it \u2014 at whatever volume they want.<\/p>\n<p>This is what powers a lot of mobile abuse in practice: credential stuffing against login endpoints, scraping data through your own API, fake account creation at scale, and abuse of any backend function that costs you money per call.<\/p>\n<p>The core issue is that your backend can verify\u00a0what\u00a0a request contains, but not\u00a0where it came from. App Check exists to close that second gap.<\/p>\n<h2>What App Check Actually Does<\/h2>\n<p>Firebase App Check issues a short-lived, cryptographically signed token proving a request came from your real, unmodified app, running on a real device. Your app attaches this token to every API call. Your backend checks that the token is valid before doing anything else.<\/p>\n<p>The token itself is generated by a platform-specific attestation provider \u2014 your app never has to compute or fake it, and crucially, neither can an attacker\u2019s script:<\/p>\n<ul>\n<li><strong>iOS<\/strong> uses Apple\u2019s App Attest (with DeviceCheck as a fallback), which gives cryptographic proof the request is coming from a genuine, unmodified app<\/li>\n<li><strong>Android<\/strong> uses Google\u2019s Play Integrity, which returns a verdict on app and device integrity<\/li>\n<li><strong>Debug builds<\/strong>\u00a0use a separate debug provider \u2014 never shipped to production<\/li>\n<\/ul>\n<p>A bot calling your API from outside the app has no way to obtain a valid token from these providers. It can\u2019t fake being a real app instance on a real device, because the attestation happens at the OS and hardware level, not inside your JS code where it could be reverse-engineered.<\/p>\n<p>Here\u2019s what the full token lifecycle looks like \u2014 from app startup through every API request:<\/p>\n<p><img decoding=\"async\" loading=\"lazy\" class=\"alignnone wp-image-80877 size-large\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-1024x638.png\" alt=\"App Check Actually Does\" width=\"625\" height=\"389\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-1024x638.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-300x187.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-768x479.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-1536x957.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-2048x1276.png 2048w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Actually-Does-624x389.png 624w\" sizes=\"(max-width: 625px) 100vw, 625px\" \/><\/p>\n<h2>Setting It Up<\/h2>\n<p>Install the App Check packages alongside your existing Firebase setup:<\/p>\n<pre>npm install @react-native-firebase\/app @react-native-firebase\/app-check<\/pre>\n<pre>cd ios &amp;&amp; pod install<\/pre>\n<p>On iOS, enable the App Attest capability in Xcode under Signing &amp; Capabilities. On Android, your app needs to be published through the Play Console, even on an internal test track \u2014 Play Integrity only issues valid tokens for Play-distributed apps.<\/p>\n<p>Initialize the provider once, at module load, switching between the debug provider and the real one based on environment:<\/p>\n<pre>import { ReactNativeFirebaseAppCheckProvider, initializeAppCheck } from \"@react-native-firebase\/app-check\";\r\nimport { getApp } from \"@react-native-firebase\/app\";\r\n\r\nconst provider = new ReactNativeFirebaseAppCheckProvider();\r\n\r\nprovider.configure({\r\n    android: {\r\n        provider: __DEV__ ? \"debug\" : \"playIntegrity\",\r\n        debugToken: process.env.FIREBASE_DEBUG_TOKEN_ANDROID,\r\n    },\r\n    apple: {\r\n        provider: __DEV__ ? \"debug\" : \"appAttest\",\r\n        debugToken: process.env.FIREBASE_DEBUG_TOKEN_IOS,\r\n    },\r\n});\r\n\r\nexport const initAppCheck = async () =&gt;\r\n    initializeAppCheck(getApp(), {\r\n        provider,\r\n        isTokenAutoRefreshEnabled: true,\r\n    });<\/pre>\n<p>Call\u00a0<strong>initAppCheck()<\/strong>\u00a0once at startup, before any authenticated requests go out.<\/p>\n<h2>Attaching the Token to Requests<\/h2>\n<p>Rather than fetching a token manually at every call site, attach it once via an interceptor on your shared API client:<\/p>\n<pre>import appCheck from '@react-native-firebase\/app-check';\r\n\r\napiClient.interceptors.request.use(async (config) =&gt; {\r\n    try {\r\n        const { token } = await appCheck().getToken(false);\r\n        config.headers['X-Firebase-AppCheck'] = token;\r\n    } catch {\r\n        \/\/ Let the request go through anyway \u2014 the backend will reject it.\r\n        \/\/ This avoids blocking legitimate users during a transient provider outage.\r\n    }\r\n    return config;\r\n});<\/pre>\n<p>With\u00a0<strong>isTokenAutoRefreshEnabled<\/strong>: true\u00a0set during initialization, the SDK refreshes the token proactively in the background.\u00a0<strong>getToken(false)<\/strong>\u00a0just returns the cached token \u2014 it isn&#8217;t making a network call on every request.<\/p>\n<h2>Enforcing It on the Backend<\/h2>\n<p>The token is only useful if your backend actually checks it. For a Node\/Express API, that\u2019s a single piece of middleware applied before any route logic runs:<\/p>\n<pre>export const appCheckMiddleware = async (req, res, next) =&gt; {\r\n    const token = req.headers['x-firebase-appcheck'];\r\n    if (!token) return res.status(401).json({ error: 'Missing App Check token' });\r\n\r\n    try {\r\n        await getAppCheck().verifyToken(token);\r\n        next();\r\n    } catch {\r\n        res.status(401).json({ error: 'Invalid App Check token' });\r\n    }\r\n};\r\n\r\napp.use(appCheckMiddleware);<\/pre>\n<p>If you\u2019re using Firestore, Realtime Database, or Cloud Functions instead of a custom backend, enforcement is a toggle in the Firebase Console \u2014 no middleware to write at all.<\/p>\n<h2>What This Actually Changes<\/h2>\n<p>Without App Check, a bot operator who extracts your API URL can script requests directly and there\u2019s nothing in their way \u2014 your\u00a0<strong>\/register<\/strong>\u00a0endpoint will happily create as many accounts as they send.<\/p>\n<p>With App Check enforced, that same bot\u2019s request arrives without a valid attestation token and gets rejected at the middleware layer, before it ever reaches your business logic. The bot has no path to obtaining a real token, because it isn\u2019t running inside your actual app on a real device.<br \/>\nPress enter or click to view image in full size<\/p>\n<p><img decoding=\"async\" loading=\"lazy\" class=\"alignnone wp-image-80876 size-large\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-1024x705.png\" alt=\"App Check - What This Actually Changes\" width=\"625\" height=\"430\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-1024x705.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-300x206.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-768x528.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-1536x1057.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-2048x1409.png 2048w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-What-This-Actually-Changes-624x429.png 624w\" sizes=\"(max-width: 625px) 100vw, 625px\" \/><\/p>\n<h2>App Check Is Not SSL Pinning<\/h2>\n<p>It\u2019s easy to conflate these two, since both involve trust and both involve tokens or certificates \u2014 but they protect against opposite problems, and you need both.<\/p>\n<p>App Check is about your\u00a0backend deciding whether to trust the client. It answers \u201cdid this request come from my real app?\u201d SSL pinning, which we\u2019ll cover in Part 6, is about your\u00a0app deciding whether to trust the server it\u2019s talking to. It answers \u201cam I actually connected to my real backend, or to a MITM proxy?\u201d<\/p>\n<p>A request can pass one and fail the other. A bot with no App Check token gets blocked regardless of SSL pinning \u2014 it was never inside your app to begin with. A man-in-the-middle proxy intercepting a legitimate request\u00a0with\u00a0a valid App Check token gets blocked by SSL pinning instead \u2014 the token is real, but the connection isn\u2019t going where it should.<\/p>\n<p><img decoding=\"async\" loading=\"lazy\" class=\"alignnone wp-image-80878 size-large\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-1024x579.png\" alt=\"App Check Is Not SSL Pinning\" width=\"625\" height=\"353\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-1024x579.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-300x170.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-768x435.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-1536x869.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-2048x1159.png 2048w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/App-Check-Is-Not-SSL-Pinning-624x353.png 624w\" sizes=\"(max-width: 625px) 100vw, 625px\" \/><\/p>\n<p>These two diagrams face in opposite directions by design \u2014 App Check is your backend defending against fake clients, SSL pinning is your app defending against fake servers. Both are necessary; neither covers the other\u2019s threat.<\/p>\n<h2>A Note on Debug Tokens<\/h2>\n<p>Never ship the debug provider to a production build. Keep debug tokens in gitignored\u00a0.env\u00a0files, one per platform per environment, and register each one in the Firebase Console under App Check \u2192 Apps \u2192 Manage debug tokens. It&#8217;s an easy thing to forget during a rushed release, and a debug token in production defeats the entire point of attestation.<\/p>\n<h2>What\u2019s Next<\/h2>\n<p>In Part 5, we\u2019ll move from protecting requests in transit to protecting data sitting on the device itself \u2014 encrypted MMKV, Keychain, and biometric gating for credentials and session data.<\/p>\n<h2>Final Thoughts<\/h2>\n<p>Device integrity and runtime protection answer \u201cis this app running in a safe environment.\u201d App Check answers a different question entirely: \u201cis this request even coming from my app at all.\u201d Both matter, and neither one is optional if your backend is reachable from the open internet \u2014 which, for almost every app, it is.<\/p>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A Secure App Still Has an Exposed Backend In\u00a0Part 3, we covered freeRASP \u2014 continuous runtime protection that watches your app for hooking, tampering, and integrity violations for the entire session, not just at startup. But here\u2019s the uncomfortable part: none of that protects your backend. Your app\u2019s JS bundle can be extracted from the [&hellip;]<\/p>\n","protected":false},"author":1769,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":2},"categories":[5881],"tags":[8768,8767],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80875"}],"collection":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/users\/1769"}],"replies":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/comments?post=80875"}],"version-history":[{"count":4,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80875\/revisions"}],"predecessor-version":[{"id":80967,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80875\/revisions\/80967"}],"wp:attachment":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/media?parent=80875"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/categories?post=80875"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/tags?post=80875"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}