Security
React Native security — secure storage, SSL pinning, code protection, authentication, data protection
Security
Mobile apps face a fundamentally different threat model than web applications. The binary ships to the user's device, runs in an environment you do not control, and communicates over networks you cannot trust. This guide covers the practical security measures that matter for React Native apps in production.
Threat Model for Mobile Apps
What Is Different From Web
On the web, your code runs on your server. On mobile, your code runs on the attacker's hardware. This changes everything:
| Concern | Web | Mobile |
|---|---|---|
| Code visibility | Server-side code is hidden | APK/IPA can be decompiled and inspected |
| Storage | HttpOnly cookies, server sessions | On-device storage accessible with root/jailbreak |
| Network | TLS terminates at your server | User can install proxy CAs and intercept traffic |
| Runtime | Sandboxed browser | Debugger attachment, Frida injection, runtime hooking |
| Distribution | Always latest version | Users run old versions with known vulnerabilities |
Threat Matrix
| Attack vector | Risk | Mitigation |
|---|---|---|
| Network interception (MITM) | Token theft, data exfiltration | Certificate pinning, TLS 1.3 |
| Reverse engineering | API key extraction, business logic theft | Hermes bytecode, ProGuard, server-side secrets |
| Local data theft (rooted device) | Credential theft, PII exposure | Secure storage (Keychain/Keystore), encryption |
| Session hijacking | Account takeover | Short-lived tokens, refresh rotation, device binding |
| Deep link hijacking | Auth code interception (OAuth) | PKCE, link validation, verified app links |
| Debug/instrumentation | Runtime manipulation, bypass checks | Debug detection, anti-tampering, RASP |
Security is defense in depth. No single measure is sufficient. Certificate pinning without secure storage is pointless — an attacker who can read your Keychain does not need to intercept network traffic. Layer your defenses and assume each layer can be bypassed individually.
Secure Storage
react-native-keychain (Recommended)
Uses iOS Keychain Services and Android Keystore — hardware-backed credential storage on both platforms.
import * as Keychain from 'react-native-keychain';
// Store credentials
async function storeToken(token: string): Promise<void> {
await Keychain.setGenericPassword('auth', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,
});
}
// Retrieve credentials
async function getToken(): Promise<string | null> {
const credentials = await Keychain.getGenericPassword();
if (credentials) {
return credentials.password;
}
return null;
}
// Clear on logout
async function clearToken(): Promise<void> {
await Keychain.resetGenericPassword();
}MMKV is NOT secure storage. react-native-mmkv is fast key-value storage, but it stores data in plaintext files. On a rooted/jailbroken device, these files are readable by any process. Even MMKV's encryption option uses a key you must store somewhere — if you store that key in MMKV, you have accomplished nothing. Use MMKV for preferences and cache; use Keychain/Keystore for credentials and tokens.
expo-secure-store (Expo Projects)
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('refreshToken', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
const stored = await SecureStore.getItemAsync('refreshToken');Biometric-Gated Access
Require biometric authentication before releasing sensitive credentials.
import * as Keychain from 'react-native-keychain';
import ReactNativeBiometrics from 'react-native-biometrics';
const biometrics = new ReactNativeBiometrics();
async function storeWithBiometric(key: string, value: string): Promise<void> {
await Keychain.setGenericPassword(key, value, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS,
});
}
async function getWithBiometric(): Promise<string | null> {
const { available } = await biometrics.isSensorAvailable();
if (!available) {
// Fall back to PIN/passcode prompt
return null;
}
try {
const credentials = await Keychain.getGenericPassword({
authenticationPrompt: {
title: 'Authenticate to access your account',
},
});
return credentials ? credentials.password : null;
} catch {
// User cancelled or biometric failed
return null;
}
}When to Use Secure Storage vs Regular Storage
| Data type | Storage | Reason |
|---|---|---|
| Auth tokens (access, refresh) | Keychain / Keystore | Credential — must be hardware-protected |
| API keys used client-side | Do not store on device | Proxy through your backend |
| User preferences (theme, locale) | MMKV / AsyncStorage | Not sensitive |
| Cached API responses | MMKV / AsyncStorage | Not sensitive; rebuild from server |
| Encryption keys | Keychain / Keystore | Key material — hardware protection required |
| PII (SSN, card numbers) | Do not store on device | Minimize what you hold; tokenize server-side |
Network Security
Certificate Pinning
Certificate pinning ensures your app only communicates with servers presenting a known certificate or public key. This defeats MITM attacks even when the user (or a corporate proxy) has installed a custom CA.
import { fetch } from 'react-native-ssl-pinning';
async function secureFetch(url: string, accessToken: string) {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
sslPinning: {
certs: ['server-cert'], // .cer file in app bundle
},
timeoutInterval: 10000,
});
return response.json();
}Pin the public key, not the certificate. Certificates rotate; public keys can persist across rotations. If you pin certificates, you must ship an app update before the old cert expires. Public key pinning (sha256/base64hash) survives certificate renewal as long as the same key pair is reused.
iOS App Transport Security
Configure ATS in Info.plist. The default policy enforces TLS 1.2+, which is correct. Only add exceptions for domains you truly cannot upgrade.
<!-- ios/YourApp/Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
<!-- Do NOT set NSAllowsArbitraryLoads to true in production -->
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<true/>
</dict>
</dict>
</dict>Android Network Security Config
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config>
<domain includeSubdomains="true">api.yourapp.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- Always include a backup pin -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>Reference it in AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>Preventing API Key Exposure
Client app ──→ Your backend proxy ──→ Third-party API
(holds the real key)Never embed third-party API keys in your JS bundle. Hermes bytecode is trivially extractable (see Code Protection below). Instead, proxy requests through your own backend, which holds the real credentials.
// Bad: API key in the JS bundle — extractable in minutes
const response = await fetch('https://maps.googleapis.com/maps/api/...', {
headers: { 'X-API-Key': 'AIzaSy...' }, // shipped to every device
});
// Good: proxy through your backend
const response = await fetch('https://api.yourapp.com/maps/geocode', {
headers: { Authorization: `Bearer ${accessToken}` },
});
// Your backend adds the Google API key server-sideHermes bytecode is not a security measure. While Hermes compiles JS to bytecode, tools like hermes-dec and hbctool can decompile it back to readable JavaScript in seconds. Treat every string literal in your JS bundle as public. This includes API keys, feature flag names, hardcoded URLs, and error messages.
Code Protection
What Hermes Bytecode Actually Protects
Hermes compiles JavaScript to bytecode (.hbc files). This provides:
- Faster startup (no parse step)
- Smaller bundle size
- Marginal increase in reverse-engineering effort
It does not provide:
- Meaningful obfuscation (decompilers exist)
- String encryption (all string literals are in a string table, trivially extractable)
- Control flow protection
ProGuard / R8 for Android
Configure in android/app/build.gradle:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}ProGuard/R8 obfuscates your Java/Kotlin native module code. It does nothing for your JavaScript layer.
String Obfuscation
For strings that must exist in the bundle (endpoint paths, feature flags), obfuscate them so they do not appear in plaintext scans:
// core/security/obfuscate.ts
// Simple XOR obfuscation — not cryptographic, but defeats grep
function deobfuscate(encoded: number[], key: number): string {
return String.fromCharCode(...encoded.map((c) => c ^ key));
}
// Pre-compute at build time, store as number arrays
// "api/v2/payments" → XOR with key 42
const PAYMENTS_ENDPOINT = deobfuscate(
[75, 90, 73, 89, 88, 84, 89, 90, 75, 91, 69, 78, 68, 82, 84],
42,
);String obfuscation raises the bar, not the ceiling. A motivated attacker will set a breakpoint and read the deobfuscated value at runtime. The goal is to defeat automated scanning tools and casual inspection — it will not stop a dedicated reverse engineer.
What an Attacker Can Extract From Your APK/IPA
| Asset | Difficulty | Mitigation |
|---|---|---|
| String literals in JS bundle | Trivial | Obfuscation, server-side secrets |
| API endpoint URLs | Trivial | Accept this; secure the endpoints |
| Hardcoded API keys | Trivial | Never embed; proxy through backend |
| Business logic flow | Moderate | Obfuscation, server-side validation |
| Native library symbols | Moderate | ProGuard/R8, strip symbols |
| Encryption keys in code | Trivial | Use Keychain/Keystore, derive at runtime |
Root/Jailbreak Detection
Basic Detection with jail-monkey
import JailMonkey from 'jail-monkey';
interface DeviceSecurityStatus {
isRooted: boolean;
canMockLocation: boolean;
isDebugMode: boolean;
isOnExternalStorage: boolean;
}
function checkDeviceSecurity(): DeviceSecurityStatus {
return {
isRooted: JailMonkey.isJailBroken(),
canMockLocation: JailMonkey.canMockLocation(),
isDebugMode: JailMonkey.isDebuggedMode(),
isOnExternalStorage: JailMonkey.isOnExternalStorage(),
};
}freeRASP for Comprehensive Protection
freeRASP by Talsec provides runtime application self-protection with broader detection coverage.
import { useFreeRasp, type TalsecConfig } from 'freerasp-react-native';
const talsecConfig: TalsecConfig = {
androidConfig: {
packageName: 'com.yourapp',
certificateHashes: ['your_signing_cert_hash'],
},
iosConfig: {
appBundleId: 'com.yourapp',
appTeamId: 'YOUR_TEAM_ID',
},
watcherMail: 'security@yourapp.com',
};
const threatCallbacks = {
onRootDetected: () => handleThreat('root'),
onDebuggerDetected: () => handleThreat('debugger'),
onTamperDetected: () => handleThreat('tamper'),
onHookDetected: () => handleThreat('hook'),
onDeviceBindingDetected: () => handleThreat('deviceBinding'),
onUnofficialStoreDetected: () => handleThreat('unofficialStore'),
};
function handleThreat(type: string): void {
// Tiered response — see below
analytics.track('security_threat', { type });
switch (type) {
case 'root':
case 'hook':
// Degrade gracefully: disable sensitive features
securityStore.setState({ restrictedMode: true });
break;
case 'tamper':
case 'debugger':
// Hard block: force logout
authStore.getState().logout();
break;
}
}
// In your App component
function App() {
useFreeRasp(talsecConfig, threatCallbacks);
return <MainNavigator />;
}Limitations and Tiered Response
Root/jailbreak detection is always bypassable. Tools like Magisk (Android) and Shadow/Liberty Lite (iOS) specifically exist to hide root status from detection libraries. Treat detection as a speed bump, not a wall. Never use it as your sole security control.
Design a tiered response instead of a binary block:
| Threat level | Detection | Response |
|---|---|---|
| Low | Rooted device, emulator | Log event, show warning, allow continued use |
| Medium | Mock location, external storage | Disable location-sensitive features |
| High | Debugger attached, hooks detected | Restrict sensitive operations, force re-auth |
| Critical | Binary tampered, unofficial store | Force logout, wipe local credentials |
Runtime Protection
Preventing Screenshots (Android)
// android/app/src/main/java/com/yourapp/ScreenProtectionModule.kt
package com.yourapp
import android.view.WindowManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class ScreenProtectionModule(
private val reactContext: ReactApplicationContext
) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "ScreenProtection"
@ReactMethod
fun enable() {
currentActivity?.runOnUiThread {
currentActivity?.window?.addFlags(
WindowManager.LayoutParams.FLAG_SECURE
)
}
}
@ReactMethod
fun disable() {
currentActivity?.runOnUiThread {
currentActivity?.window?.clearFlags(
WindowManager.LayoutParams.FLAG_SECURE
)
}
}
}Usage from JS:
import { NativeModules } from 'react-native';
const { ScreenProtection } = NativeModules;
// Enable on sensitive screens
function PaymentScreen() {
useEffect(() => {
ScreenProtection.enable();
return () => ScreenProtection.disable();
}, []);
return <PaymentForm />;
}App Backgrounding Snapshot Protection (iOS)
iOS takes a snapshot of your app when it enters the background (for the app switcher). This can expose sensitive content.
// Hook to overlay a blur/splash when app backgrounds
import { useEffect } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
export function useAppBackgroundProtection() {
useEffect(() => {
const handleChange = (state: AppStateStatus) => {
if (state === 'inactive' || state === 'background') {
// Show a privacy overlay (splash screen, blur)
privacyOverlayStore.setState({ visible: true });
} else if (state === 'active') {
privacyOverlayStore.setState({ visible: false });
}
};
const subscription = AppState.addEventListener('change', handleChange);
return () => subscription.remove();
}, []);
}Debug Detection
// core/security/debugDetection.ts
export function isDebugEnvironment(): boolean {
if (__DEV__) return true;
// Android: check for debugger attachment
// This requires a native module for reliable detection
return false;
}
export function enforceReleaseMode(): void {
if (__DEV__) {
console.warn('Running in debug mode — security checks relaxed');
return;
}
// In release, verify integrity
const checks = [
!__DEV__,
typeof atob === 'function', // Hermes environment check
];
if (checks.some((c) => !c)) {
// Environment has been tampered with
authStore.getState().logout();
}
}Authentication Security
Secure Token Storage Pattern
The standard pattern: keep the short-lived access token in memory, store the long-lived refresh token in Keychain/Keystore.
// core/auth/tokenManager.ts
import * as Keychain from 'react-native-keychain';
class TokenManager {
// Access token lives in memory only — never persisted to disk
private accessToken: string | null = null;
async setTokens(access: string, refresh: string): Promise<void> {
this.accessToken = access;
await Keychain.setGenericPassword('refresh', refresh, {
service: 'com.yourapp.refresh-token',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
getAccessToken(): string | null {
return this.accessToken;
}
async getRefreshToken(): Promise<string | null> {
const creds = await Keychain.getGenericPassword({
service: 'com.yourapp.refresh-token',
});
return creds ? creds.password : null;
}
async clearAll(): Promise<void> {
this.accessToken = null;
await Keychain.resetGenericPassword({
service: 'com.yourapp.refresh-token',
});
}
}
export const tokenManager = new TokenManager();Refresh Token Rotation
// core/auth/refreshTokenRotation.ts
import { tokenManager } from './tokenManager';
let refreshPromise: Promise<boolean> | null = null;
export async function refreshAccessToken(): Promise<boolean> {
// Deduplicate concurrent refresh attempts
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) return false;
const response = await fetch('https://api.yourapp.com/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
await tokenManager.clearAll();
return false;
}
const { accessToken, refreshToken: newRefreshToken } =
await response.json();
// Server issues a new refresh token — old one is invalidated
await tokenManager.setTokens(accessToken, newRefreshToken);
return true;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}Biometric Authentication
import ReactNativeBiometrics from 'react-native-biometrics';
const biometrics = new ReactNativeBiometrics({
allowDeviceCredentials: true, // fall back to device PIN
});
export async function authenticateUser(): Promise<boolean> {
const { available, biometryType } = await biometrics.isSensorAvailable();
if (!available) {
// Device has no biometric hardware — fall back to password
return false;
}
const { success } = await biometrics.simplePrompt({
promptMessage: `Sign in with ${biometryType}`,
cancelButtonText: 'Use password',
});
return success;
}Session Timeout
// core/auth/sessionTimeout.ts
import { AppState, type AppStateStatus } from 'react-native';
const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
class SessionTimeoutManager {
private backgroundedAt: number | null = null;
start(): void {
AppState.addEventListener('change', this.handleAppStateChange);
}
private handleAppStateChange = (state: AppStateStatus): void => {
if (state === 'background') {
this.backgroundedAt = Date.now();
} else if (state === 'active' && this.backgroundedAt) {
const elapsed = Date.now() - this.backgroundedAt;
this.backgroundedAt = null;
if (elapsed > SESSION_TIMEOUT_MS) {
// Session expired — require re-authentication
authStore.getState().requireReauth();
}
}
};
}
export const sessionTimeout = new SessionTimeoutManager();OAuth 2.0 + PKCE
Use react-native-app-auth for standards-compliant OAuth flows. PKCE (Proof Key for Code Exchange) is mandatory for mobile — it prevents authorization code interception.
import { authorize, type AuthConfiguration } from 'react-native-app-auth';
const config: AuthConfiguration = {
issuer: 'https://auth.yourapp.com',
clientId: 'mobile-app',
redirectUrl: 'com.yourapp://oauth/callback',
scopes: ['openid', 'profile', 'offline_access'],
usePKCE: true, // generates code_verifier + code_challenge automatically
additionalParameters: {
prompt: 'consent',
},
};
export async function login(): Promise<void> {
try {
const result = await authorize(config);
await tokenManager.setTokens(
result.accessToken,
result.refreshToken ?? '',
);
} catch (error) {
// User cancelled or auth failed
throw new AuthenticationError('Login failed', { cause: error });
}
}Deep Link Validation
Deep links can be hijacked by malicious apps registering the same scheme. Use verified app links (Android) and universal links (iOS) to prevent this.
// Validate incoming deep links before acting on them
import { Linking } from 'react-native';
const ALLOWED_HOSTS = ['app.yourapp.com', 'yourapp.com'];
async function handleDeepLink(url: string): Promise<void> {
const parsed = new URL(url);
// Reject links from unexpected origins
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
console.warn(`Rejected deep link from unknown host: ${parsed.hostname}`);
return;
}
// Validate path structure before navigating
const oauthCallback = parsed.pathname.match(/^\/oauth\/callback/);
if (oauthCallback) {
const code = parsed.searchParams.get('code');
const state = parsed.searchParams.get('state');
if (!code || !state) {
console.warn('Malformed OAuth callback — missing code or state');
return;
}
// Validate state parameter matches what we generated
const expectedState = await SecureStore.getItemAsync('oauth_state');
if (state !== expectedState) {
console.warn('OAuth state mismatch — possible CSRF');
return;
}
await exchangeCodeForTokens(code);
}
}Data Protection
Console.log Ships in Production
console.log is not stripped in production React Native builds by default. Unlike web bundlers that tree-shake console calls, Metro keeps them. If you log tokens, user data, or API responses during development, that data is written to the device log in production — accessible via adb logcat on Android and Console.app on iOS.
Strip Console in Production
Use babel-plugin-transform-remove-console:
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};Or selectively strip only certain levels:
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
env: {
production: {
plugins: [
['transform-remove-console', { exclude: ['error', 'warn'] }],
],
},
},
};Securing WebView
WebView is a frequent attack surface. Treat it with the same caution you would an iframe on the web.
import { WebView } from 'react-native-webview';
function SecureWebView({ url }: { url: string }) {
return (
<WebView
source={{ uri: url }}
// Restrict which origins can load
originWhitelist={['https://trusted.yourapp.com']}
// Disable JavaScript if content is static
javaScriptEnabled={false}
// Prevent navigation to unexpected URLs
onShouldStartLoadWithRequest={(request) => {
return request.url.startsWith('https://trusted.yourapp.com');
}}
// Do NOT use injectedJavaScript for sensitive operations
// It runs in the WebView context and can be intercepted
/>
);
}Never pass tokens via injectedJavaScript. Any JavaScript injected into a WebView runs in the web context and can be read by the loaded page. If your WebView needs to authenticate, use cookie-based auth set by your server, or use postMessage with origin checks and short-lived, scoped tokens.
GDPR and Data Minimization
- Do not store PII on device unless strictly necessary. Tokenize server-side.
- Implement a "delete my data" flow that clears both server and device storage.
- Audit every
AsyncStorage/MMKVkey your app writes — each one is a data retention liability. - If you use analytics, ensure they are GDPR-compliant and consent-gated before initializing.
// core/storage/gdprWipe.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
export async function wipeAllUserData(): Promise<void> {
// 1. Clear secure credentials
await Keychain.resetGenericPassword();
await Keychain.resetGenericPassword({ service: 'com.yourapp.refresh-token' });
// 2. Clear fast storage
storage.clearAll();
// 3. Clear async storage
await AsyncStorage.clear();
// 4. Request server-side deletion
await fetch('https://api.yourapp.com/user/delete', {
method: 'DELETE',
headers: { Authorization: `Bearer ${lastAccessToken}` },
});
}Security Checklist
Pre-Release Security Checklist
| Category | Check | Priority |
|---|---|---|
| Storage | Auth tokens stored in Keychain/Keystore, not AsyncStorage/MMKV | Critical |
| Storage | No sensitive data in plaintext on disk | Critical |
| Storage | MMKV/AsyncStorage audited for accidental PII | High |
| Network | Certificate pinning enabled for all API endpoints | High |
| Network | No cleartext HTTP traffic allowed (ATS / network security config) | Critical |
| Network | API keys proxied through backend, not in JS bundle | Critical |
| Code | console.log stripped in production builds | High |
| Code | No hardcoded secrets in source (scan with trufflehog or gitleaks) | Critical |
| Code | ProGuard/R8 enabled for Android release builds | Medium |
| Code | Hermes enabled (bytecode, not plaintext JS) | Medium |
| Auth | Access tokens are short-lived (< 15 min) | High |
| Auth | Refresh token rotation implemented | High |
| Auth | Session timeout on app background | Medium |
| Auth | OAuth flows use PKCE | Critical |
| Auth | Deep links validated before processing | High |
| Runtime | Root/jailbreak detection with tiered response | Medium |
| Runtime | Screenshot prevention on sensitive screens | Medium |
| Runtime | App backgrounding snapshot protection | Medium |
| Runtime | Debug detection in release builds | Low |
| WebView | Origin whitelist configured | High |
| WebView | No tokens passed via injectedJavaScript | Critical |
| Data | GDPR data deletion flow implemented | High (EU) |
| Data | Analytics consent-gated before initialization | High (EU) |
| Supply chain | Dependencies audited (npm audit, Snyk) | High |
| Supply chain | Lock file committed, no floating versions | Medium |