Expo应用安全与数据保护有哪些最佳实践?
Expo应用的安全性和数据保护是移动开发中不可回避的核心问题。从敏感数据存储到网络通信加密,从身份验证到代码防护,Expo提供了一套完整的安全工具链。本文围绕实际开发场景,逐一拆解Expo应用安全的关键环节和对应方案。
敏感数据的安全存储
移动应用中最常见的安全风险就是敏感数据明文存储。密码、Token、API密钥等信息一旦被提取,后果严重。Expo提供了expo-secure-store作为一线方案。
SecureStore 基本用法
typescriptimport * as SecureStore from 'expo-secure-store'; // 保存敏感数据 async function saveToken(token: string) { try { await SecureStore.setItemAsync('userToken', token, { keychainAccessible: SecureStore.WHEN_UNLOCKED, }); } catch (error) { console.error('Failed to save token:', error); } } // 读取敏感数据 async function getToken(): Promise<string | null> { try { return await SecureStore.getItemAsync('userToken'); } catch (error) { console.error('Failed to get token:', error); return null; } } // 删除敏感数据 async function deleteToken() { try { await SecureStore.deleteItemAsync('userToken'); } catch (error) { console.error('Failed to delete token:', error); } }
SecureStore 的平台差异与边界
SecureStore底层依赖平台原生加密机制——iOS使用Keychain,Android使用Keystore。两者行为存在关键差异:
- iOS:卸载后重装同bundle ID的应用,Keychain数据仍可恢复
- Android:卸载应用时Keystore数据会被清除
另外,requireAuthentication: true选项可要求生物识别后才能访问数据,但如果用户更改了生物识别设置,已保护的数据将无法访问。因此,SecureStore不应作为不可替代数据的唯一存储位置,关键数据需要有服务端备份方案。
非敏感数据的存储选择
对于非敏感配置信息,AsyncStorage即可满足需求,不需要引入SecureStore的加密开销。判断标准很简单:如果数据泄露不会造成安全风险,就用AsyncStorage。
API密钥与敏感配置的管理
API密钥硬编码在客户端代码中是最常见的安全漏洞之一,无论代码混淆多强,逆向工程都能提取出来。
环境变量方案
Expo支持通过EXPO_PUBLIC_前缀的环境变量在客户端访问配置值:
typescriptconst API_URL = process.env.EXPO_PUBLIC_API_URL; const API_KEY = process.env.EXPO_PUBLIC_API_KEY;
但要注意:EXPO_PUBLIC_前缀的变量会打包进客户端bundle,本质上仍然是客户端可见的。它们适合存放不同环境的配置URL,而不是真正的密钥。
EAS Secrets 方案
对于构建时需要的真正密钥(如签名密钥、第三方服务Secret Key),应使用EAS Secrets:
bash# 添加构建密钥 eas secret:create --name STRIPE_SECRET_KEY --value "sk_test_xxx" # 按环境区分 eas secret:create --name GOOGLE_MAPS_API_KEY --value "AIzaSyxxx" --scope production
EAS Secrets仅在EAS Build服务器上解密使用,不会打包进客户端。
后端代理方案
对于运行时需要使用的密钥,最安全的做法是通过后端代理转发请求:
typescriptconst fetchSecureData = async () => { const response = await fetch('https://api.example.com/data', { headers: { 'Authorization': `Bearer ${await getToken()}`, }, }); return response.json(); };
客户端只持有用户认证Token,所有需要API密钥的请求都由后端处理。
身份验证与授权
JWT Token 管理
JWT是最常见的移动端认证方案。完整的Token管理需要处理保存、刷新、过期三个环节:
typescriptimport * as SecureStore from 'expo-secure-store'; async function saveAuthToken(token: string) { await SecureStore.setItemAsync('authToken', token); } async function getAuthToken(): Promise<string | null> { return await SecureStore.getItemAsync('authToken'); } async function refreshToken(): Promise<string> { const refreshToken = await SecureStore.getItemAsync('refreshToken'); const response = await fetch('https://api.example.com/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }); const { token } = await response.json(); await saveAuthToken(token); return token; }
Token刷新策略建议:在请求拦截器中检测401响应,自动触发刷新并重试原始请求,避免用户感知到Token过期。
OAuth 集成
Expo通过expo-auth-session提供了标准OAuth流程支持:
typescriptimport * as AuthSession from 'expo-auth-session'; const discovery = { authorizationEndpoint: 'https://auth.example.com/authorize', tokenEndpoint: 'https://auth.example.com/token', }; async function authenticate() { const request = new AuthSession.AuthRequest({ clientId: 'your-client-id', scopes: ['openid', 'profile'], redirectUri: AuthSession.makeRedirectUri({ scheme: 'myapp', }), }); const result = await request.promptAsync(discovery); if (result.type === 'success') { const { accessToken } = result.params; await saveAuthToken(accessToken); return accessToken; } }
注意clientId应通过环境变量注入,不要硬编码在代码中。
网络安全防护
HTTPS 强制
所有网络请求必须走HTTPS。在iOS上可通过App Transport Security配置强制执行:
json{ "expo": { "ios": { "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": false } } } } }
NSAllowsArbitraryLoads: false会阻止所有HTTP请求,确保通信加密。
在代码层面也可以加一层防护:
typescriptconst secureFetch = async (url: string, options?: RequestInit) => { if (!url.startsWith('https://')) { throw new Error('Only HTTPS requests are allowed'); } return fetch(url, options); };
响应数据验证
不要信任服务端返回的任何数据结构,前端必须做类型校验:
typescriptinterface ApiResponse<T> { data: T; success: boolean; message?: string; } async function fetchValidatedData<T>(url: string): Promise<T> { const response = await fetch(url); const data: ApiResponse<T> = await response.json(); if (!data.success) { throw new Error(data.message || 'Request failed'); } return data.data; }
防御CSRF攻击
对于涉及状态变更的请求,使用CSRF Token进行验证:
typescriptasync function fetchWithCSRF(url: string, options?: RequestInit) { const csrfToken = await SecureStore.getItemAsync('csrfToken'); return fetch(url, { ...options, headers: { ...options?.headers, 'X-CSRF-Token': csrfToken || '', }, }); }
输入验证与XSS防护
表单输入校验
用户输入是攻击的主要入口,必须在客户端和服务端同时验证:
typescript// 验证邮箱格式 const validateEmail = (email: string): boolean => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); }; // 验证密码强度:至少8字符,含大小写字母和数字 const validatePassword = (password: string): boolean => { const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/; return passwordRegex.test(password); }; // 验证手机号 const validatePhone = (phone: string): boolean => { const phoneRegex = /^1[3-9]\d{9}$/; return phoneRegex.test(phone); };
XSS 防护
React Native的<Text>组件默认不解析HTML,因此XSS风险主要出现在使用WebView渲染用户内容的场景。如果确实需要渲染用户输入的HTML,必须转义特殊字符:
typescriptconst escapeHtml = (unsafe: string): string => { return unsafe .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); };
应用安全配置
app.json 安全配置
json{ "expo": { "ios": { "bundleIdentifier": "com.yourcompany.yourapp", "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": false } } }, "android": { "package": "com.yourcompany.yourapp", "permissions": [] } } }
关键配置项:
NSAllowsArbitraryLoads: false:iOS禁止HTTP请求permissions: []:Android最小权限原则,不申请不需要的权限bundleIdentifier/package:使用反向域名格式,避免与其它应用冲突
权限最小化
只在确实需要时才请求权限,并且向用户说明用途。Expo中可以在app.json声明所需权限,未声明的权限不会被打包。
日志与安全监控
错误追踪
生产环境必须接入错误监控服务(如Sentry),实时捕获异常:
typescriptimport * as Sentry from '@sentry/react-native'; Sentry.init({ dsn: 'your-sentry-dsn', environment: __DEV__ ? 'development' : 'production', }); try { // 业务代码 } catch (error) { Sentry.captureException(error); }
安全事件记录
对关键安全事件(登录失败、权限变更、数据导出等)进行审计日志记录:
typescriptconst logSecurityEvent = async (event: string, details: any) => { if (!__DEV__) { await fetch('https://logs.example.com/security', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, details, timestamp: Date.now() }), }); } };
常见安全威胁与对应方案
| 威胁类型 | 攻击原理 | 防御方案 |
|---|---|---|
| 中间人攻击 | 拦截客户端与服务端之间的通信 | HTTPS + ATS强制加密 |
| 数据泄露 | 设备丢失或被root/越狱后数据被提取 | SecureStore加密存储 + 不持久化非必要数据 |
| 逆向工程 | 反编译APK/IPA提取代码逻辑 | EAS Build代码混淆 + 密钥后端托管 |
| 重放攻击 | 截获合法请求并重复发送 | 请求签名 + 时间戳 + nonce |
| SQL注入 | 通过输入字段拼接恶意SQL | 参数化查询 + ORM框架 |
| 凭证填充 | 利用泄露的账号密码批量尝试登录 | 限流 + 多因素认证 + 异常检测 |
安全审计清单
上线前对照以下清单逐项检查:
- 所有敏感数据通过SecureStore存储,未使用AsyncStorage或明文存储
- API密钥和Secret Key通过EAS Secrets或后端代理管理,未硬编码在客户端
- 所有网络请求使用HTTPS,iOS已配置ATS
- JWT Token有刷新机制和过期处理
- 用户输入在客户端和服务端均做了校验
- 权限声明遵循最小化原则,无多余权限
- 已接入错误监控服务,关键安全事件有审计日志
- Android权限和iOS权限声明已审查