Skip to content

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)

#RuleSection
1Tokens in SecureStore (override Supabase default AsyncStorage)1.1
2Token refresh mutex (prevent race conditions)1.2
3PKCE for OAuth flows1.3
4Kakao token validated server-side via Edge Function1.6
5No secrets in EXPO_PUBLIC_ variables2.2
6API keys proxied through Edge Functions2.3
7RLS enabled on every table with correct policies2.5
8Input validation on client (Zod) AND server (CHECK constraints)2.6
8bNo string interpolation in Supabase .not() / .or() / .filter()2.7
8cExplicit per-function REVOKE EXECUTE FROM PUBLIC on every SECURITY DEFINER function (public AND private schema)2.8
9OTA code signing enabled3.6
10HTTPS only4.1

Important (Before Public Launch)

#RuleSection
11No tokens in Zustand persist2.1
12ProGuard/R8 enabled for Android3.1
13Universal Links instead of custom URL schemes3.2
14Deep link parameter validation (Zod)3.2
15console.log stripped in production3.5
16Server-side rate limiting4.3
17CORS on Edge Functions4.4

Nice-to-Have (Phase 2+)

#RuleSection
18Biometric auth for sensitive actions1.4
19SSL certificate pinning2.4
20Clipboard auto-clear3.3
21Screenshot prevention on payment screens3.4
22HMAC request signing for payments/scores4.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 TypeStorageWhy
Access/refresh tokensSecureStoreHardware-backed encryption
Kakao OAuth tokensSecureStoreSensitive credentials
Session IDSecureStoreSession hijacking risk
User preferences (theme)MMKV/AsyncStorageNon-sensitive
Cached API responsesMMKV/AsyncStorageNon-sensitive
Encryption keysSecureStoreMust be in secure enclave
typescript
// 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.

typescript
// 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.

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.

typescript
// 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.

toml
# supabase config.toml
[auth]
jwt_expiry = 3600
refresh_token_rotation_enabled = true
refresh_token_reuse_interval = 10
typescript
supabase.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.

typescript
// 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.

typescript
// 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:

  1. Every table has ENABLE ROW LEVEL SECURITY
  2. Every table has at least one policy
  3. Use TO authenticated to block anon where needed
  4. Wrap auth.uid() in (SELECT ...) for performance
  5. UPDATE policies have both USING and WITH CHECK
  6. Never use user_metadata — use app_metadata
  7. Views use security_invoker = true (Postgres 15+)

RLS Silent Failures:

OperationOn Policy Deny
SELECTReturns 0 rows (silent)
UPDATEAffects 0 rows (silent)
DELETEAffects 0 rows (silent)
INSERTThrows error (loud)

2.6 Input Validation

RULE: Validate with Zod on client AND database CHECK constraints on server. Never trust client-side validation alone.

sql
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.

typescript
// 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:

  1. club-alert adapter.not() call with interpolated userId in a subquery. Fixed by splitting into two safe parameterized queries.
  2. post adapter (unread count).or() with interpolated userId for unread post count. Fixed by using parameterized .eq() chain.
  3. club-search adapter.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/GRANT pair — the schema-level statement is not trusted as sufficient.
  • One documented exemption pattern is allowed: a private SECDEF helper invoked from inside an RLS policy's USING/WITH CHECK clause runs as the invoking role, not the definer — revoking PUBLIC EXECUTE from it breaks the policy. TwoMore's one live exemption is has_attended_club_session (used in membership_recommendations_insert's WITH CHECK), explicitly documented at the grant site.
  • Mechanical guard: pgTAP supabase/tests/00026_secdef_acl_hygiene.test.sql asserts no private-schema SECDEF function grants EXECUTE to anon/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.

javascript
// app.config.js
[
  'expo-build-properties',
  {
    android: {
      enableProguardInReleaseBuilds: true,
      enableShrinkResourcesInReleaseBuilds: true,
    },
  },
];

RULE: Use Universal/App Links. Validate all incoming parameters. Never pass roles/tokens in deep links.

typescript
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

typescript
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.

bash
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.pem

Key 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.

typescript
export const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://app.twomore.kr',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Sources

Markdown remains the source of truth. Run yarn docs:check before handoff.