AsyncStorage và SharedPreferences là chữ thường, ai mở được file là đọc được token. Cách lưu token bằng Keychain và Keystore, chặn sao lưu, chỉ dùng HTTPS, khi nào nên ghim chứng chỉ, che màn hình trong trình chuyển app và giữ deep link không tự làm việc nguy hiểm.
Nhờ AI viết phần đăng nhập cho app React Native, bạn rất hay nhận được dòng này:
await AsyncStorage.setItem("token", accessToken);
Nó chạy, đăng nhập giữ được qua mỗi lần mở app. Nhưng AsyncStorage lưu dữ liệu dưới dạng chữ thường không mã hóa. Trên Android, đó là một file SQLite trong thư mục của app. Máy đã root, bản sao lưu bị trích xuất, hay một công cụ debug cắm vào máy là đọc được ngay. Với Flutter, SharedPreferences cũng vậy.
Token đăng nhập trên điện thoại giống chìa khóa nhà để dưới tấm thảm chùi chân: tiện, ai đi ngang cũng không thấy, cho tới khi có người lật tấm thảm lên.
Chỗ cất đúng: Keychain và Keystore
iOS có Keychain, Android có Keystore. Cả hai do hệ điều hành quản lý, khóa mã hóa nằm trong phần cứng bảo mật trên máy đời mới, app khác không đọc được.
React Native với Expo dùng expo-secure-store. Theo tài liệu của Expo, trên iOS nó lưu vào Keychain, trên Android lưu vào SharedPreferences đã mã hóa bằng khóa trong Android Keystore:
import * as SecureStore from "expo-secure-store";await SecureStore.setItemAsync("refresh_token", refreshToken, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,});const token = await SecureStore.getItemAsync("refresh_token");await SecureStore.deleteItemAsync("refresh_token"); // khi đăng xuất
WHEN_UNLOCKED_THIS_DEVICE_ONLY nghĩa là chỉ đọc được khi máy đã mở khóa, và không theo bản sao lưu sang máy khác. Expo cũng ghi chú một số bản iOS cũ từ chối giá trị dài hơn khoảng 2048 byte, nên lưu token, đừng lưu cả object hồ sơ người dùng.
React Native không dùng Expo có react-native-keychain. Flutter có flutter_secure_storage, cũng dựa trên Keychain và Keystore.
Với thao tác nhạy cảm (chuyển tiền, xem thông tin y tế), expo-secure-store có tùy chọn requireAuthentication: true bắt người dùng xác thực vân tay hay Face ID mỗi lần đọc. Lưu ý tài liệu của Expo: thêm vân tay mới hay đổi khuôn mặt là khóa bị vô hiệu, lần đọc sau trả về null, nên app phải xử lý được trường hợp đó bằng cách đăng nhập lại.
Lưu cái gì, lưu bao lâu
Access token sống ngắn (vài phút tới một giờ), giữ trong bộ nhớ là đủ.
Refresh token sống lâu hơn, đây là thứ cần cất vào Keychain hay Keystore.
Đăng xuất thì xóa token trên máy và thu hồi refresh token trên server. Chỉ xóa trên máy thì bản đã bị chép vẫn dùng được.
Không lưu mật khẩu người dùng trên máy, kể cả trong Keychain. Đã có refresh token thì không cần.
Tắt sao lưu cho token
Android Auto Backup có thể sao lưu dữ liệu app lên Google Drive và khôi phục sang máy mới. Theo tài liệu của Expo, nếu bạn không có cấu hình sao lưu riêng thì config plugin của expo-secure-store tự loại thư mục của nó khỏi bản sao lưu (tùy chọn configureAndroidBackup, mặc định bật). Nếu bạn đã tự viết backup_rules.xml hay data_extraction_rules.xml, phải tự thêm dòng loại trừ, và tắt tùy chọn đó đi.
Nếu app cũ đang lưu token trong AsyncStorage, nhớ rằng file đó đang được sao lưu. Chuyển sang SecureStore xong thì xóa khóa cũ khỏi AsyncStorage.
Trên iOS, các mức ..._THIS_DEVICE_ONLY của Keychain giữ token không theo bản sao lưu sang máy khác.
Chỉ nói chuyện qua HTTPS
Android từ bản 9 (API 28) mặc định chặn kết nối HTTP không mã hóa, iOS có App Transport Security cũng chặn mặc định. Lỗi hay gặp là lúc dev, AI thêm dòng mở khóa cho tiện rồi quên gỡ:
<!-- AndroidManifest.xml: đừng để dòng này lên bản phát hành --><application android:usesCleartextTraffic="true" ...>
<!-- Info.plist: tương tự --><key>NSAllowsArbitraryLoads</key><true/>
Nếu dev cần gọi http://192.168.1.10:3000, chỉ mở cho đúng địa chỉ đó trong cấu hình debug, không mở toàn bộ.
Có nên ghim chứng chỉ
Ghim chứng chỉ (certificate pinning) là app chỉ tin một số khóa công khai cụ thể của server, nên kể cả khi máy người dùng bị cài một chứng chỉ gốc lạ thì kết nối vẫn bị từ chối. Nghe chắc, nhưng tài liệu của Android cảnh báo: phải luôn có một pin dự phòng, nếu không thì khi đổi khóa hay đổi nhà cấp chứng chỉ, app sẽ mất kết nối cho tới khi người dùng cập nhật bản mới.
Với chứng chỉ ngày càng ngắn hạn, và nhà cung cấp hạ tầng có thể đổi khóa bất cứ lúc nào, phần lớn app nhỏ không nên ghim. Ứng dụng tài chính, y tế, hay app có yêu cầu riêng thì có thể cân nhắc, ghim vào khóa của CA trung gian kèm khóa dự phòng, và có kế hoạch cập nhật. Trên Android làm bằng pin-set trong Network Security Config.
Che màn hình trong trình chuyển app
Khi người dùng vuốt ra màn hình đa nhiệm, hệ điều hành chụp lại màn hình app để làm ảnh thu nhỏ. Màn hình đang hiện số dư tài khoản, mã OTP hay tin nhắn riêng tư sẽ nằm trong ảnh đó.
Android: đặt cờ FLAG_SECURE cho cửa sổ để chặn chụp màn hình, quay màn hình và làm trống ảnh thu nhỏ trong Recents. Với React Native có các thư viện bọc sẵn, hoặc expo-screen-capture (preventScreenCaptureAsync).
iOS: không có cờ tương đương. Cách thường dùng là khi app sắp vào nền (AppState chuyển sang inactive), phủ một màn che lên nội dung.
Chỉ làm cho màn hình có thông tin nhạy cảm, đừng chặn chụp màn hình toàn app nếu không cần, người dùng sẽ khó chịu.
Deep link không được tự làm việc nguy hiểm
Deep link dạng myapp://... là scheme tự đặt. Tài liệu của Android nói rõ: app nào cũng khai báo được cùng scheme đó, và người dùng có thể được hỏi chọn app để mở. Nghĩa là link của bạn có thể bị app khác nhận, và app của bạn có thể nhận link do bất kỳ trang web, tin nhắn nào gửi tới.
Vài nguyên tắc:
Link đăng nhập, đặt lại mật khẩu, callback OAuth dùng Android App Links và iOS Universal Links (link https:// đã xác minh với tên miền của bạn), không dùng scheme tự đặt.
Deep link chỉ mở màn hình, không tự thực hiện hành động. myapp://transfer?to=123&amount=500000 phải mở màn xác nhận, người dùng bấm thì mới chuyển.
Không nhận token qua tham số deep link rồi lưu ngay. Với OAuth, dùng PKCE để mã nhận về vô dụng nếu bị app khác chặn.
Kiểm và giới hạn mọi tham số trong link như với input từ người lạ.
Kiểm tra app của bạn trong vài phút
Tìm trong code: grep -rn "AsyncStorage.setItem\|SharedPreferences\|localStorage" src lib app và xem có dòng nào lưu token không.
Tìm cấu hình mở HTTP: grep -rn "usesCleartextTraffic\|NSAllowsArbitraryLoads\|cleartextTrafficPermitted" android ios.
Mở app, vào màn hình có thông tin nhạy cảm, vuốt ra màn hình đa nhiệm. Đọc được nội dung trong ảnh thu nhỏ là cần che.
Liệt kê deep link của app (trong app.json, AndroidManifest.xml, Info.plist). Thử mở từng link từ trình duyệt bằng tham số lạ, xem app có tự làm gì mà không hỏi không.
Đăng xuất, rồi gọi API refresh bằng refresh token cũ (lấy từ bản build debug của chính bạn). Còn ra token mới là server chưa thu hồi.
di độngtokenđăng nhập/anh/web/app-di-dong-luu-token-o-dau-480.webpdi động · token · đăng nhập
App di dong luu token dang nhap o dau cho an toan AsyncStorage va SharedPreferences la chu thuong, ai mo duoc file la doc duoc token. Cach luu token bang Keychain va Keystore, chan sao luu, chi dung HTTPS, khi nao nen ghim chung chi, che man hinh trong trinh chuyen app va giu deep link khong tu lam viec nguy hiem. di dong token dang nhap Nho AI viet phan dang nhap cho app React Native, ban rat hay nhan duoc dong nay:
```ts
await AsyncStorage.setItem("token", accessToken);
```
No chay, dang nhap giu duoc qua moi lan mo app. Nhung AsyncStorage luu du lieu duoi dang chu thuong khong ma hoa. Tren Android, do la mot file SQLite trong thu muc cua app. May da root, ban sao luu bi trich xuat, hay mot cong cu debug cam vao may la doc duoc ngay. Voi Flutter, `SharedPreferences` cung vay.
Token dang nhap tren dien thoai giong chia khoa nha de duoi tam tham chui chan: tien, ai di ngang cung khong thay, cho toi khi co nguoi lat tam tham len.

## Cho cat dung: Keychain va Keystore
iOS co Keychain, Android co Keystore. Ca hai do he dieu hanh quan ly, khoa ma hoa nam trong phan cung bao mat tren may doi moi, app khac khong doc duoc.
**React Native voi Expo** dung `expo-secure-store`. Theo tai lieu cua Expo, tren iOS no luu vao Keychain, tren Android luu vao SharedPreferences da ma hoa bang khoa trong Android Keystore:
```ts
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("refresh_token", refreshToken, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
const token = await SecureStore.getItemAsync("refresh_token");
await SecureStore.deleteItemAsync("refresh_token"); // khi dang xuat
```
`WHEN_UNLOCKED_THIS_DEVICE_ONLY` nghia la chi doc duoc khi may da mo khoa, va khong theo ban sao luu sang may khac. Expo cung ghi chu mot so ban iOS cu tu choi gia tri dai hon khoang 2048 byte, nen luu token, dung luu ca object ho so nguoi dung.
**React Native khong dung Expo** co `react-native-keychain`. **Flutter** co `flutter_secure_storage`, cung dua tren Keychain va Keystore.
Voi thao tac nhay cam (chuyen tien, xem thong tin y te), `expo-secure-store` co tuy chon `requireAuthentication: true` bat nguoi dung xac thuc van tay hay Face ID moi lan doc. Luu y tai lieu cua Expo: them van tay moi hay doi khuon mat la khoa bi vo hieu, lan doc sau tra ve `null`, nen app phai xu ly duoc truong hop do bang cach dang nhap lai.
## Luu cai gi, luu bao lau
- Access token song ngan (vai phut toi mot gio), giu trong bo nho la du.
- Refresh token song lau hon, day la thu can cat vao Keychain hay Keystore.
- Dang xuat thi xoa token tren may **va** thu hoi refresh token tren server. Chi xoa tren may thi ban da bi chep van dung duoc.
- Khong luu mat khau nguoi dung tren may, ke ca trong Keychain. Da co refresh token thi khong can.
## Tat sao luu cho token
Android Auto Backup co the sao luu du lieu app len Google Drive va khoi phuc sang may moi. Theo tai lieu cua Expo, neu ban khong co cau hinh sao luu rieng thi config plugin cua `expo-secure-store` tu loai thu muc cua no khoi ban sao luu (tuy chon `configureAndroidBackup`, mac dinh bat). Neu ban da tu viet `backup_rules.xml` hay `data_extraction_rules.xml`, phai tu them dong loai tru, va tat tuy chon do di.
Neu app cu dang luu token trong AsyncStorage, nho rang file do **dang** duoc sao luu. Chuyen sang SecureStore xong thi xoa khoa cu khoi AsyncStorage.
Tren iOS, cac muc `..._THIS_DEVICE_ONLY` cua Keychain giu token khong theo ban sao luu sang may khac.
## Chi noi chuyen qua HTTPS
Android tu ban 9 (API 28) mac dinh chan ket noi HTTP khong ma hoa, iOS co App Transport Security cung chan mac dinh. Loi hay gap la luc dev, AI them dong mo khoa cho tien roi quen go:
```xml
<!-- AndroidManifest.xml: dung de dong nay len ban phat hanh -->
<application android:usesCleartextTraffic="true" ...>
```
```xml
<!-- Info.plist: tuong tu -->
<key>NSAllowsArbitraryLoads</key><true/>
```
Neu dev can goi `http://192.168.1.10:3000`, chi mo cho dung dia chi do trong cau hinh debug, khong mo toan bo.
## Co nen ghim chung chi
Ghim chung chi (certificate pinning) la app chi tin mot so khoa cong khai cu the cua server, nen ke ca khi may nguoi dung bi cai mot chung chi goc la thi ket noi van bi tu choi. Nghe chac, nhung tai lieu cua Android canh bao: phai luon co mot **pin du phong**, neu khong thi khi doi khoa hay doi nha cap chung chi, app se mat ket noi cho toi khi nguoi dung cap nhat ban moi.
Voi chung chi ngay cang ngan han, va nha cung cap ha tang co the doi khoa bat cu luc nao, phan lon app nho khong nen ghim. Ung dung tai chinh, y te, hay app co yeu cau rieng thi co the can nhac, ghim vao khoa cua CA trung gian kem khoa du phong, va co ke hoach cap nhat. Tren Android lam bang `pin-set` trong Network Security Config.
## Che man hinh trong trinh chuyen app
Khi nguoi dung vuot ra man hinh da nhiem, he dieu hanh chup lai man hinh app de lam anh thu nho. Man hinh dang hien so du tai khoan, ma OTP hay tin nhan rieng tu se nam trong anh do.
- **Android:** dat co `FLAG_SECURE` cho cua so de chan chup man hinh, quay man hinh va lam trong anh thu nho trong Recents. Voi React Native co cac thu vien boc san, hoac `expo-screen-capture` (`preventScreenCaptureAsync`).
- **iOS:** khong co co tuong duong. Cach thuong dung la khi app sap vao nen (`AppState` chuyen sang `inactive`), phu mot man che len noi dung.
```tsx
const [hidden, setHidden] = useState(false);
useEffect(() => {
const sub = AppState.addEventListener("change", (s) => setHidden(s !== "active"));
return () => sub.remove();
}, []);
return <>{children}{hidden && <View style={StyleSheet.absoluteFill}><SplashCover /></View>}</>;
```
Chi lam cho man hinh co thong tin nhay cam, dung chan chup man hinh toan app neu khong can, nguoi dung se kho chiu.
## Deep link khong duoc tu lam viec nguy hiem
Deep link dang `myapp://...` la scheme tu dat. Tai lieu cua Android noi ro: app nao cung khai bao duoc cung scheme do, va nguoi dung co the duoc hoi chon app de mo. Nghia la link cua ban co the bi app khac nhan, va app cua ban co the nhan link do bat ky trang web, tin nhan nao gui toi.
Vai nguyen tac:
- Link dang nhap, dat lai mat khau, callback OAuth dung **Android App Links** va **iOS Universal Links** (link `https://` da xac minh voi ten mien cua ban), khong dung scheme tu dat.
- Deep link chi mo man hinh, khong tu thuc hien hanh dong. `myapp://transfer?to=123&amount=500000` phai mo man xac nhan, nguoi dung bam thi moi chuyen.
- Khong nhan token qua tham so deep link roi luu ngay. Voi OAuth, dung PKCE de ma nhan ve vo dung neu bi app khac chan.
- Kiem va gioi han moi tham so trong link nhu voi input tu nguoi la.
## Kiem tra app cua ban trong vai phut
1. Tim trong code: `grep -rn "AsyncStorage.setItem\|SharedPreferences\|localStorage" src lib app` va xem co dong nao luu token khong.
2. Tim cau hinh mo HTTP: `grep -rn "usesCleartextTraffic\|NSAllowsArbitraryLoads\|cleartextTrafficPermitted" android ios`.
3. Mo app, vao man hinh co thong tin nhay cam, vuot ra man hinh da nhiem. Doc duoc noi dung trong anh thu nho la can che.
4. Liet ke deep link cua app (trong `app.json`, `AndroidManifest.xml`, `Info.plist`). Thu mo tung link tu trinh duyet bang tham so la, xem app co tu lam gi ma khong hoi khong.
5. Dang xuat, roi goi API refresh bang refresh token cu (lay tu ban build debug cua chinh ban). Con ra token moi la server chua thu hoi.
---
App di dong cua ban dang luu token bang gi? Vao [cong dong Vibe Code & Bao Mat](https://www.facebook.com/groups/1867387324580254) ke cho moi nguoi nghe nhe.
## Nguon
- [SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/), Expo Docs
- [Network security configuration](https://developer.android.com/privacy-and-security/security-config), Android Developers
- [About Android App Links](https://developer.android.com/training/app-links), Android Developers
- [MASVS-STORAGE](https://mas.owasp.org/MASVS/05-MASVS-STORAGE/), OWASP Mobile Application Security