Security & Authentication Best Practices
Status: Reference
React Native (Expo SDK 55) + Supabase + Kakao Login Researched 2026-03-24. Sources: OWASP MAS, React Native docs, Expo docs, Supabase docs, Kakao Developers.
Priority Matrix
Critical (Before Production)
| # | Rule | Section |
|---|---|---|
| 1 | Tokens in SecureStore (override Supabase default AsyncStorage) | 1.1 |
| 2 | Token refresh mutex (prevent race conditions) | 1.2 |
| 3 | PKCE for OAuth flows | 1.3 |
| 4 | Kakao token validated server-side via Edge Function | 1.6 |
| 5 | No secrets in EXPO_PUBLIC_ variables | 2.2 |
| 6 | API keys proxied through Edge Functions | 2.3 |
| 7 | RLS enabled on every table with correct policies | 2.5 |
| 8 | Input validation on client (Zod) AND server (CHECK constraints) | 2.6 |
| 8b | No string interpolation in Supabase .not() / .or() / .filter() | 2.7 |
| 8c | Explicit per-function REVOKE EXECUTE FROM PUBLIC on every SECURITY DEFINER function (public AND private schema) | 2.8 |
| 9 | OTA code signing enabled | 3.6 |
| 10 | HTTPS only | 4.1 |
Important (Before Public Launch)
| # | Rule | Section |
|---|---|---|
| 11 | No tokens in Zustand persist | 2.1 |
| 12 | ProGuard/R8 enabled for Android | 3.1 |
| 13 | Universal Links instead of custom URL schemes | 3.2 |
| 14 | Deep link parameter validation (Zod) | 3.2 |
| 15 | console.log stripped in production | 3.5 |
| 16 | Server-side rate limiting | 4.3 |
| 17 | CORS on Edge Functions | 4.4 |
Nice-to-Have (Phase 2+)
| # | Rule | Section |
|---|---|---|
| 18 | Biometric auth for sensitive actions | 1.4 |
| 19 | SSL certificate pinning | 2.4 |
| 20 | Clipboard auto-clear | 3.3 |
| 21 | Screenshot prevention on payment screens | 3.4 |
| 22 | HMAC request signing for payments/scores | 4.2 |
1. Authentication
1.1 Token Storage — SecureStore Only
RULE: Store all auth tokens (access, refresh, session) in expo-secure-store. Never AsyncStorage. MMKV acceptable only with encryption key in SecureStore.
WHY: AsyncStorage is unencrypted plaintext. Rooted/jailbroken devices or backup extraction reveals all stored data. OWASP Mobile Top 10 #2: Insecure Data Storage.
Storage Decision Matrix:
| Data Type | Storage | Why |
|---|---|---|
| Access/refresh tokens | SecureStore | Hardware-backed encryption |
| Kakao OAuth tokens | SecureStore | Sensitive credentials |
| Session ID | SecureStore | Session hijacking risk |
| User preferences (theme) | MMKV/AsyncStorage | Non-sensitive |
| Cached API responses | MMKV/AsyncStorage | Non-sensitive |
| Encryption keys | SecureStore | Must be in secure enclave |
// BAD
await AsyncStorage.setItem('access_token', token);
// GOOD
await SecureStore.setItemAsync('access_token', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});TwoMore: Supabase session tokens (JWT) MUST be in SecureStore. Override the default @supabase/supabase-js auth storage with a SecureStore adapter.
1.2 Token Refresh — Mutex Pattern
RULE: Use a mutex/lock for token refresh. Never allow concurrent refresh requests. Queue pending requests behind a single refresh operation.
WHY: When multiple API calls get 401 simultaneously, each triggers a refresh. With refresh token rotation (Supabase uses this), only the first succeeds — subsequent ones use an already-invalidated token, causing mass logout.
// BAD — race condition
if (response.status === 401) {
await supabase.auth.refreshSession(); // Multiple calls race here
}
// GOOD — mutex guarded
import { Mutex } from 'async-mutex';
const refreshMutex = new Mutex();
const refreshTokenSafely = async (): Promise<boolean> => {
if (refreshMutex.isLocked()) {
await refreshMutex.waitForUnlock();
return true; // Another caller already refreshed
}
const release = await refreshMutex.acquire();
try {
const { error } = await supabase.auth.refreshSession();
return !error;
} finally {
release();
}
};TwoMore: Supabase JS client does NOT handle race conditions when multiple TanStack Query hooks fire simultaneously. Wrap with mutex-guarded interceptor.
1.3 OAuth Flows — PKCE + Universal Links
RULE: Always use PKCE for OAuth. Validate the state parameter. Use Universal Links (iOS) / App Links (Android) instead of custom URL schemes.
WHY: Custom URL schemes (twomore://) can be registered by any app. A malicious app can intercept the OAuth callback.
// GOOD
const { data } = await supabase.auth.signInWithOAuth({
provider: 'kakao',
options: {
redirectTo: 'https://auth.twomore.app/callback', // Universal link
queryParams: { flowType: 'pkce' },
},
});
// Validate callback origin
const handleAuthCallback = async (url: string) => {
const parsedUrl = new URL(url);
if (!parsedUrl.hostname.match(/^(auth\.twomore\.app|.*\.supabase\.co)$/)) {
throw new Error('Invalid auth callback origin');
}
// ...
};1.4 Biometric Auth
RULE: Use expo-local-authentication for biometric checks. Store credentials with requireAuthentication: true. Always provide PIN/password fallback.
Priority: Nice-to-have (Phase 2 — for payments, admin actions).
1.5 Session Management
RULE: Short-lived access tokens (15-60 min), longer refresh tokens (7-30 days). Clear TanStack Query cache on sign-out.
# supabase config.toml
[auth]
jwt_expiry = 3600
refresh_token_rotation_enabled = true
refresh_token_reuse_interval = 10supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_OUT') {
queryClient.clear(); // Clear ALL cached data
router.replace('/login');
}
});1.6 Kakao Login — Server-Side Validation
RULE: Never trust client-side Kakao tokens. Validate server-side via Edge Function before creating Supabase session.
// BAD — client token could be forged
await supabase.auth.signInWithIdToken({
provider: 'kakao',
token: kakaoToken.accessToken,
});
// GOOD — server validates with Kakao API
const { data } = await supabase.functions.invoke('auth-kakao', {
body: { kakao_access_token: kakaoResult.accessToken },
});
if (data?.session) await supabase.auth.setSession(data.session);2. Data Security
2.1 Secure Storage Classification
RULE: Never persist sensitive data via Zustand persist (uses AsyncStorage). Auth tokens belong in SecureStore only.
2.2 Environment Variables
RULE: EXPO_PUBLIC_* is bundled into JS and visible in plain text. Never put secrets there.
OK in EXPO_PUBLIC_: Supabase URL, anon key (public by design), Kakao native app key (bundle ID restricted).
MUST be in EAS Secrets / Edge Functions: Kakao admin key, payment keys, service role keys, NCP Map client secret.
2.3 API Key Protection
RULE: Never bundle third-party API keys in the client. Proxy through Edge Functions.
// BAD — extractable from bundle
fetch('https://api.naver.com', { headers: { 'X-API-KEY': 'SECRET' } });
// GOOD — Edge Function holds secret
supabase.functions.invoke('proxy-naver-maps', { body: { query } });2.4 Certificate Pinning
Priority: Nice-to-have now. Important when handling payments.
2.5 RLS (Row Level Security)
RULE: Enable RLS on EVERY table. Never trust user_metadata in policies (use app_metadata). Test positive AND negative cases.
Critical RLS Checklist:
- Every table has
ENABLE ROW LEVEL SECURITY - Every table has at least one policy
- Use
TO authenticatedto block anon where needed - Wrap
auth.uid()in(SELECT ...)for performance - UPDATE policies have both
USINGandWITH CHECK - Never use
user_metadata— useapp_metadata - Views use
security_invoker = true(Postgres 15+)
RLS Silent Failures:
| Operation | On Policy Deny |
|---|---|
| SELECT | Returns 0 rows (silent) |
| UPDATE | Affects 0 rows (silent) |
| DELETE | Affects 0 rows (silent) |
| INSERT | Throws error (loud) |
2.6 Input Validation
RULE: Validate with Zod on client AND database CHECK constraints on server. Never trust client-side validation alone.
ALTER TABLE sessions ADD CONSTRAINT max_players_check
CHECK (max_players BETWEEN 4 AND 32);2.7 PostgREST / Supabase Filter Safety
RULE: Never use string interpolation in Supabase .not(), .or(), .filter() calls. All user-provided values must go through Supabase's parameterized methods (.eq(), .in(), .is(), .neq(), etc.), never via template literals.
WHY: PostgREST filter arguments are passed as raw strings to the query engine. Template literals inside .not() or .or() bypass parameterization, enabling SQL injection even with RLS enabled. An attacker who controls the interpolated value can break out of the filter and execute arbitrary SQL.
// BAD — SQL injection via string interpolation
const { data } = await supabase
.from('club_alerts')
.select('*')
.not('id', 'in', `(SELECT dismissed_alert_id FROM dismissed_alerts WHERE user_id = '${userId}')`);
// GOOD — two safe parameterized queries
const { data: dismissed } = await supabase
.from('dismissed_alerts')
.select('dismissed_alert_id')
.eq('user_id', userId);
const dismissedIds = dismissed?.map((d) => d.dismissed_alert_id) ?? [];
const query = supabase.from('club_alerts').select('*');
if (dismissedIds.length > 0) {
query.not('id', 'in', `(${dismissedIds.join(',')})`); // IDs from our own DB, not user input
}
const { data } = await query;
// BEST — RPC with server-side parameterized SQL
const { data } = await supabase.rpc('get_undismissed_alerts', { p_user_id: userId });Decision Tree:
- Need a subquery filter? → Use an RPC (server-side parameterized SQL)
- Can split into two queries? → Fetch IDs first, then
.in()/.not('id', 'in', ...)with DB-sourced values - Simple equality/range? → Use
.eq(),.neq(),.gt(),.in()directly — these are always safe
TwoMore vulnerabilities found and fixed:
club-alertadapter —.not()call with interpolateduserIdin a subquery. Fixed by splitting into two safe parameterized queries.postadapter (unread count) —.or()with interpolateduserIdfor unread post count. Fixed by using parameterized.eq()chain.club-searchadapter —.or()with interpolated search query string for club name/description search. Fixed by escaping special characters and using parameterized.ilike().
Filter injection escaping pattern: When user input must appear in a PostgREST filter string (e.g., .ilike() with a search term), escape all special characters (%, _, \) before interpolation. Use the escapeFilterValue() utility in src/adapters/utils/filter-escape.ts. This prevents users from injecting wildcard patterns or breaking out of the filter.
Audit all adapters for this pattern when adding new filters.
2.8 SECURITY DEFINER Function Execute Grants
RULE: Every SECURITY DEFINER function — in public AND in private — needs its own explicit REVOKE EXECUTE ON FUNCTION <fn> FROM PUBLIC; plus an explicit GRANT EXECUTE ... TO <only the roles that need it>; in the migration that creates it. Never rely on a schema-level ALTER DEFAULT PRIVILEGES statement to cover functions created later.
WHY: A SECURITY DEFINER function runs with the definer's privileges, so if PUBLIC can execute it, RLS on the tables it touches is irrelevant — the function itself is the privilege boundary. TwoMore migration 00425 (2026-07-13) found, live, that ALTER DEFAULT PRIVILEGES ... REVOKE EXECUTE ... FROM PUBLIC does not reliably suppress Postgres's hardwired default PUBLIC-EXECUTE ACL on functions created after the statement: two earlier migrations (00209, 00393) each claimed their schema-level revoke "closes it at source," but a fresh audit still found 51 of 108 private-schema SECDEF functions carrying the default PUBLIC EXECUTE grant.
TwoMore discipline:
- Every new SECDEF function ships with its own inline
REVOKE/GRANTpair — the schema-level statement is not trusted as sufficient. - One documented exemption pattern is allowed: a
privateSECDEF helper invoked from inside an RLS policy'sUSING/WITH CHECKclause runs as the invoking role, not the definer — revoking PUBLIC EXECUTE from it breaks the policy. TwoMore's one live exemption ishas_attended_club_session(used inmembership_recommendations_insert'sWITH CHECK), explicitly documented at the grant site. - Mechanical guard: pgTAP
supabase/tests/00026_secdef_acl_hygiene.test.sqlasserts noprivate-schema SECDEF function grants EXECUTE toanon/authenticated/PUBLIC, with the one exemption allow-listed by name — a function that forgets the inline revoke fails CI instead of shipping a silent privilege-escalation hole.
3. App Security
3.1 Code Obfuscation
RULE: Hermes compiles JS to bytecode (meaningful obfuscation). Enable ProGuard/R8 for Android native code.
// app.config.js
[
'expo-build-properties',
{
android: {
enableProguardInReleaseBuilds: true,
enableShrinkResourcesInReleaseBuilds: true,
},
},
];3.2 Deep Link Validation
RULE: Use Universal/App Links. Validate all incoming parameters. Never pass roles/tokens in deep links.
const handleDeepLink = (url: string) => {
const parsed = new URL(url);
if (parsed.hostname !== 'app.twomore.kr') return;
const clubId = parsed.searchParams.get('clubId');
if (clubId && !z.string().uuid().safeParse(clubId).success) return;
router.push(`/(app)/(tabs)/(clubs)/${clubId}`);
};3.3 Clipboard Security
Auto-clear sensitive data after 30 seconds. Priority: Nice-to-have.
3.4 Screenshot Prevention
Use expo-screen-capture on payment/PII screens. Priority: Phase 2.
3.5 Debug Mode — Strip console.log
if (!__DEV__) {
console.log = () => {};
console.warn = () => {};
}Or use babel-plugin-transform-remove-console in production babel config.
3.6 OTA Update Code Signing
RULE: Sign EAS Updates with a private key. Client verifies before applying.
npx expo-updates codesigning:generate \
--key-output-directory ../keys \
--certificate-output-directory certs \
--certificate-validity-duration-years 10 \
--certificate-common-name "TwoMore Dev Team"
eas update --private-key-path ../keys/private-key.pemKey management: private-key.pem NEVER in source control — store in CI secrets. certificate.pem checked into repo.
4. Network Security
4.1 HTTPS Enforcement
All requests HTTPS. Android Network Security Config blocks plaintext. iOS ATS blocks by default.
4.2 Request Signing (HMAC)
For critical mutations (score submission, payment). Priority: Phase 2+.
4.3 Rate Limiting
Client-side: isPending from TanStack Query mutations prevents double-submit. Server-side: Edge Function rate limit or DB timestamp checks.
4.4 CORS on Edge Functions
Explicitly configure. Restrict allowed origins.
export const corsHeaders = {
'Access-Control-Allow-Origin': 'https://app.twomore.kr',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};