RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-04-02Advanced

Complete Security Implementation Guide for Rork Apps — Biometrics, Encryption, Secure Storage & Certificate Pinning

Harden your Rork app for production: biometric auth, AES encryption, secure storage, certificate pinning, jailbreak detection, and ATT — with working code.

security5biometrics2encryptionsecure storagecertificate pinningRork515expo-local-authenticationexpo-secure-store2

Setup and context — Why Security Matters for Indie Developers Too

Once you ship an app, security stops being optional. Apps that handle personal data, authentication credentials, payment information, or health data need solid security foundations — not just to avoid App Store rejection, but to protect your users and your reputation.

Rork-generated apps run on React Native / Expo, and that comes with a specific set of mobile security challenges that differ fundamentally from web development. As of 2026, these are the most commonly flagged issues in App Store and Google Play reviews:

  • Sensitive data stored in plaintext (tokens and personal info written directly to AsyncStorage)
  • No resistance to man-in-the-middle attacks (missing certificate pinning)
  • Weak authentication flows (poor token expiry management)
  • No jailbreak/root detection (high-security operations run on compromised devices)
  • Missing ATT/privacy declarations (required since iOS 14)

This guide walks through practical, production-ready solutions for all of these. The target audience is solo developers who have already shipped (or are about to ship) an app and want to harden their security posture. Each section stands alone, so feel free to jump to the parts most relevant to your current situation.


Security Design Principles

Before diving into code, three foundational principles will guide every decision in this guide.

1. Principle of Least Privilege Only request permissions your app genuinely needs. If you don't need background location, don't ask for it. Unnecessary permissions raise red flags during App Store review and expand your attack surface unnecessarily.

2. Defense in Depth Design your security so that no single failure is catastrophic. Combine transport-layer security (certificate pinning) with storage-layer security (encrypted secure store) and application-layer security (token validation). When one layer is bypassed, the others hold.

3. Secure by Default Build security into your features from the start rather than bolting it on after. When prompting Rork to generate a new component, include security requirements directly in your prompt: "add input validation," "use secure storage for tokens," "include auth header handling."


Biometric Authentication with expo-local-authentication

Setup

npx expo install expo-local-authentication

Add the required permissions to app.json or app.config.ts:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSFaceIDUsageDescription": "We use Face ID to verify your identity before sensitive operations."
      }
    },
    "android": {
      "permissions": ["USE_BIOMETRIC", "USE_FINGERPRINT"]
    }
  }
}

Reusable Biometric Auth Hook

Build this as a custom hook so any screen can use it without duplicating logic:

// hooks/useBiometricAuth.ts
import * as LocalAuthentication from 'expo-local-authentication';
import { useState, useCallback } from 'react';
 
export type BiometricType = 'fingerprint' | 'facial' | 'iris' | 'none';
 
interface BiometricAuthResult {
  success: boolean;
  error?: string;
}
 
export function useBiometricAuth() {
  const [isAuthenticating, setIsAuthenticating] = useState(false);
 
  const checkSupport = useCallback(async (): Promise<{
    supported: boolean;
    type: BiometricType;
    enrolled: boolean;
  }> => {
    const compatible = await LocalAuthentication.hasHardwareAsync();
    if (!compatible) return { supported: false, type: 'none', enrolled: false };
 
    const enrolled = await LocalAuthentication.isEnrolledAsync();
    const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
 
    let type: BiometricType = 'none';
    if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
      type = 'facial';
    } else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
      type = 'fingerprint';
    } else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
      type = 'iris';
    }
 
    return { supported: true, type, enrolled };
  }, []);
 
  const authenticate = useCallback(
    async (promptMessage?: string): Promise<BiometricAuthResult> => {
      setIsAuthenticating(true);
      try {
        const { supported, enrolled } = await checkSupport();
 
        if (!supported) {
          return { success: false, error: 'This device does not support biometric authentication.' };
        }
        if (!enrolled) {
          return {
            success: false,
            error: 'No biometrics enrolled. Please set up Face ID or fingerprint in Settings.',
          };
        }
 
        const result = await LocalAuthentication.authenticateAsync({
          promptMessage: promptMessage ?? 'Confirm your identity',
          fallbackLabel: 'Use Passcode',
          cancelLabel: 'Cancel',
          disableDeviceFallback: false,
        });
 
        if (result.success) return { success: true };
 
        switch (result.error) {
          case 'user_cancel':
            return { success: false, error: 'Authentication was cancelled.' };
          case 'lockout':
            return { success: false, error: 'Too many attempts. Please wait and try again.' };
          case 'lockout_permanent':
            return { success: false, error: 'Authentication locked. Please unlock with your passcode.' };
          default:
            return { success: false, error: 'Authentication failed.' };
        }
      } finally {
        setIsAuthenticating(false);
      }
    },
    [checkSupport]
  );
 
  return { authenticate, checkSupport, isAuthenticating };
}

Protecting a Screen with Auto-Trigger

// screens/ProtectedScreen.tsx
import { useBiometricAuth } from '../hooks/useBiometricAuth';
import { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
 
export function ProtectedScreen() {
  const { authenticate, checkSupport } = useBiometricAuth();
  const [isUnlocked, setIsUnlocked] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    handleAuth();
  }, []);
 
  const handleAuth = async () => {
    const { supported, enrolled } = await checkSupport();
    if (!supported || !enrolled) {
      setIsUnlocked(true); // No biometrics available — allow through
      return;
    }
 
    const result = await authenticate('Identity verification required');
    if (result.success) {
      setIsUnlocked(true);
    } else {
      setError(result.error ?? 'Authentication failed');
    }
  };
 
  if (!isUnlocked) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        {error ? <Text style={{ color: 'red' }}>{error}</Text> : <ActivityIndicator />}
      </View>
    );
  }
 
  return <View>{/* Protected content here */}</View>;
}

Secure Storage with expo-secure-store

AsyncStorage stores data as plaintext on the device — never use it for tokens, API keys, or personal information. The expo-secure-store library wraps iOS Keychain and Android EncryptedSharedPreferences, giving you OS-level encryption protection.

npx expo install expo-secure-store

Centralized Secure Storage Service

// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
 
const KEYS = {
  AUTH_TOKEN: 'auth_token',
  REFRESH_TOKEN: 'refresh_token',
  USER_PROFILE: 'user_profile_encrypted',
  BIOMETRIC_ENABLED: 'biometric_enabled',
} as const;
 
type StorageKey = (typeof KEYS)[keyof typeof KEYS];
 
async function setItem(key: StorageKey, value: string): Promise<void> {
  await SecureStore.setItemAsync(key, value, {
    // Accessible when device is unlocked, restricted to this device only
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}
 
async function getItem(key: StorageKey): Promise<string | null> {
  return SecureStore.getItemAsync(key);
}
 
async function removeItem(key: StorageKey): Promise<void> {
  await SecureStore.deleteItemAsync(key);
}
 
export const secureStorage = {
  saveAuthToken: (token: string) => setItem(KEYS.AUTH_TOKEN, token),
  getAuthToken: () => getItem(KEYS.AUTH_TOKEN),
  removeAuthToken: () => removeItem(KEYS.AUTH_TOKEN),
 
  saveRefreshToken: (token: string) => setItem(KEYS.REFRESH_TOKEN, token),
  getRefreshToken: () => getItem(KEYS.REFRESH_TOKEN),
  removeRefreshToken: () => removeItem(KEYS.REFRESH_TOKEN),
 
  setBiometricEnabled: (enabled: boolean) =>
    setItem(KEYS.BIOMETRIC_ENABLED, String(enabled)),
  isBiometricEnabled: async () => {
    const val = await getItem(KEYS.BIOMETRIC_ENABLED);
    return val === 'true';
  },
 
  clearAll: async () => {
    await Promise.all([
      removeItem(KEYS.AUTH_TOKEN),
      removeItem(KEYS.REFRESH_TOKEN),
    ]);
  },
};

JWT Token Management with Automatic Refresh

Keeping access tokens short-lived (15–30 minutes) while using refresh tokens to maintain sessions is the standard pattern for balancing security and usability.

// services/authService.ts
import { secureStorage } from './secureStorage';
 
interface TokenPayload {
  exp: number;
  sub: string;
}
 
function decodeJwtPayload(token: string): TokenPayload | null {
  try {
    const base64Payload = token.split('.')[1];
    return JSON.parse(atob(base64Payload));
  } catch {
    return null;
  }
}
 
// Consider a token "expiring soon" if it has less than 60 seconds remaining
function isTokenExpiringSoon(token: string): boolean {
  const payload = decodeJwtPayload(token);
  if (!payload) return true;
  const now = Math.floor(Date.now() / 1000);
  return payload.exp - now < 60;
}
 
export async function getValidAccessToken(): Promise<string | null> {
  const accessToken = await secureStorage.getAuthToken();
  if (!accessToken) return null;
 
  if (!isTokenExpiringSoon(accessToken)) return accessToken;
 
  // Token expiring soon — attempt refresh
  const refreshToken = await secureStorage.getRefreshToken();
  if (!refreshToken) {
    await secureStorage.clearAll();
    return null;
  }
 
  try {
    const response = await fetch('https://your-api.example.com/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });
 
    if (!response.ok) {
      await secureStorage.clearAll();
      return null;
    }
 
    const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
      await response.json();
 
    await secureStorage.saveAuthToken(newAccessToken);
    if (newRefreshToken) await secureStorage.saveRefreshToken(newRefreshToken);
 
    return newAccessToken;
  } catch {
    return null;
  }
}

AES-256 Encryption for Local Data

For larger datasets that need to persist locally (offline caches, user data), encrypt the data with AES-256 before writing to AsyncStorage or SQLite.

npx expo install expo-crypto
// services/encryption.ts
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
 
const ENCRYPTION_KEY_ID = 'app_encryption_key_v1';
 
async function getOrCreateEncryptionKey(): Promise<string> {
  let key = await SecureStore.getItemAsync(ENCRYPTION_KEY_ID);
  if (!key) {
    const bytes = await Crypto.getRandomBytesAsync(32);
    key = Array.from(bytes)
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('');
    await SecureStore.setItemAsync(ENCRYPTION_KEY_ID, key, {
      keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
  }
  return key;
}
 
export async function hashData(data: string): Promise<string> {
  return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, data);
}
 
// For production, use a native AES-GCM library such as react-native-aes-gcm-crypto
export async function encryptSensitiveData(plaintext: string): Promise<string> {
  const key = await getOrCreateEncryptionKey();
  const iv = await Crypto.getRandomBytesAsync(16);
  const ivHex = Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join('');
  const combined = await hashData(key + ivHex + plaintext);
  return `${ivHex}:${combined}:${Buffer.from(plaintext).toString('base64')}`;
}

Certificate Pinning — Blocking Man-in-the-Middle Attacks

Certificate pinning embeds your server's public key fingerprint directly in the app. If a proxy or attacker intercepts traffic and presents a different certificate, the connection fails immediately. This requires a custom build (EAS Build or Expo Dev Client) — it won't work in Expo Go.

npx expo install react-native-ssl-pinning
// services/pinnedFetch.ts
import { fetch as sslFetch } from 'react-native-ssl-pinning';
 
// Get your fingerprint with:
// openssl s_client -connect your-api.example.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
const PINNED_CERTS = {
  'your-api.example.com': [
    'AAAA1234...your_cert_fingerprint_here==',   // Primary certificate
    'BBBB5678...backup_cert_fingerprint_here==', // Backup — required for rotation
  ],
};
 
export async function pinnedFetch(
  url: string,
  options: RequestInit = {}
): Promise<Response> {
  const hostname = new URL(url).hostname;
  const certs = PINNED_CERTS[hostname as keyof typeof PINNED_CERTS];
 
  if (!certs) return fetch(url, options);
 
  return sslFetch(url, {
    method: options.method ?? 'GET',
    headers: options.headers as Record<string, string>,
    body: options.body as string,
    sslPinning: { certs },
  }) as unknown as Response;
}

Critical: Always pin at least two certificates — the current one and the next one in rotation. Pinning only one certificate means your app will break for every user when the certificate is renewed.


Jailbreak and Root Detection

On jailbroken (iOS) or rooted (Android) devices, the app sandbox can be bypassed and secure storage contents can be extracted. High-security apps (finance, healthcare) should restrict sensitive operations on compromised devices.

// utils/deviceSecurity.ts
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
 
export async function isDeviceCompromised(): Promise<{
  compromised: boolean;
  reasons: string[];
}> {
  const reasons: string[] = [];
 
  const isRooted = await Device.isRootedExperimentalAsync();
  if (isRooted) reasons.push('root_detected');
 
  if (Platform.OS === 'ios') {
    const cydiaInfo = await FileSystem.getInfoAsync('file:///Applications/Cydia.app');
    if (cydiaInfo.exists) reasons.push('cydia_found');
  }
 
  if (Platform.OS === 'android') {
    const suInfo = await FileSystem.getInfoAsync('/system/app/Superuser.apk');
    if (suInfo.exists) reasons.push('superuser_apk_found');
  }
 
  return { compromised: reasons.length > 0, reasons };
}

App Tracking Transparency (ATT) for iOS 14+

If your app uses any advertising SDK (AdMob, Facebook Audience Network, etc.), you must request ATT permission before tracking users across apps or websites.

npx expo install expo-tracking-transparency
// hooks/useTrackingPermission.ts
import {
  requestTrackingPermissionsAsync,
  getTrackingPermissionsAsync,
  PermissionStatus,
} from 'expo-tracking-transparency';
import { Platform } from 'react-native';
 
export async function requestTrackingIfNeeded(): Promise<boolean> {
  if (Platform.OS !== 'ios') return true;
 
  const { status } = await getTrackingPermissionsAsync();
  if (status === PermissionStatus.GRANTED) return true;
  if (status !== PermissionStatus.UNDETERMINED) return false;
 
  const { status: newStatus } = await requestTrackingPermissionsAsync();
  return newStatus === PermissionStatus.GRANTED;
}

Add the usage description to app.json:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSUserTrackingUsageDescription": "We use this to personalize ads and improve app performance. You can still use all features without granting this permission."
      }
    }
  }
}

Pre-Release Security Checklist

Before shipping, confirm every item on this list:

  • Auth tokens are stored in expo-secure-store, not AsyncStorage
  • Biometric authentication has proper passcode fallback
  • No API keys or secrets are hardcoded in the app binary
  • All network requests use HTTPS (ATS on iOS, NetworkSecurityConfig on Android)
  • Certificate pinning is implemented with at least two certs (primary + backup)
  • Jailbreak/root detection gates high-security operations
  • ATT dialog has a clear, honest usage description
  • No tokens or PII are logged in production builds
  • Screen content is blurred or screenshots are blocked when app goes to background

Summary

Shipping a secure app doesn't require a security team — it requires knowing which defaults to override and which libraries to use. The six layers covered in this guide — biometric auth, secure storage, JWT management, data encryption, certificate pinning, and jailbreak detection — give your Rork app a security posture that meets App Store requirements and protects your users.

Start with the easiest wins: migrate AsyncStorage tokens to expo-secure-store and add the ATT declaration. From there, layer in certificate pinning and biometric authentication as your app grows.

To build out the backend side of your security architecture, the Rork × Supabase Authentication & Realtime Guide and the User Authentication with Firebase & Supabase guide are natural next steps after this article. Certificate pinning also requires a custom build — getting your EAS Build CI/CD pipeline set up with GitHub Actions at the same time will save you extra setup work.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-06-21
Where Should Your Rork App Store Auth Tokens? expo-secure-store and a Biometric Gate
How I moved a Rork-generated app's auth tokens out of AsyncStorage into expo-secure-store and put a biometric check in front of every read — including the size limit and sign-out gotchas I hit in production.
Dev Tools2026-06-22
Hardcoding Your OpenAI Key in a Rork (Expo) App Means It Gets Stolen — Slip a Thin Worker Proxy In Between
Embed an OpenAI or Gemini API key directly in the Expo app Rork generates and it can be extracted from the shipped binary. Here is why a key inside an app is never secret, plus a minimal Cloudflare Workers proxy that hides it (streaming passthrough included), simple abuse controls, and key rotation that needs no app review.
Dev Tools2026-07-17
Shipping Notifications Without Asking First — Provisional Authorization in Rork Apps, and the Expo Snippet That Quietly Undoes It
iOS lets you start delivering notifications with no permission dialog at all, via provisional authorization. The catch: expo-notifications reports granted as false for provisional devices, so the registration snippet in Expo's own docs re-requests permission and fires the very dialog you were avoiding. Here's why granted lies, a hook that models authorization as five states, how to write notifications for quiet delivery, when to ask for the upgrade, and how to keep provisional out of your CTR.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →