Capacitor 앱에서 Better Auth를 사용하려면, better-auth 웹 code에 client를 설치하고, Better Auth 서버를 가리키도록 설정한 뒤, Capacitor origin을 추가하세요.capacitor://localhost iOS에서는, https://localhost Android에서는) 하여 trustedOrigins, and switch from cookies to bearer tokens with the bearer plugin을 통해 이메일과 비밀번호 방식은 SDK에서 그대로 작동합니다. Google 및 Apple의 경우, 다음 native plugin을 사용하여 ID token을 가져온 뒤 @capgo/capacitor-social-login 에 전달합니다. authClient.signIn.social({ idToken }).
이 가이드에서는 서버 및 클라이언트 설정, token 저장, 소셜 로그인, enterprise provider, 그리고 Better Auth 웹 앱을 Capacitor로 전환할 때 발생하는 오류들을 다룹니다.
Capacitor 내에서 Better Auth를 실행할 때 달라지는 점
Capacitor 앱은 native WebView 내부의 local origin에서 서빙되는 web app입니다. 일반적인 웹사이트와 비교했을 때 세 가지 차이점이 있습니다:
| 주제 | 웹사이트의 경우 | Capacitor 앱의 경우 |
|---|---|---|
| Origin | https://app.example.com |
capacitor://localhost (iOS), https://localhost (Android) |
| 인증 서버 | 동일 사이트인 경우 쿠키가 정상 작동함 | 도메인이 다르면 WKWebView에서 서드파티 쿠키가 차단됨 |
| OAuth 리다이렉트로 거부합니다 | 전체 페이지 리다이렉트는 문제없음 | 리다이렉트 시 번들 앱을 벗어나게 되며, Google은 WebView를 차단함 |
해결 방법은 다음과 같습니다: Capacitor origin을 신뢰하고, bearer token을 사용하며, 소셜 로그인을 위해 native plugin을 사용하는 것입니다.
1단계: Better Auth 서버 설정
이 예제는 Hono를 사용하지만, Better Auth 설정은 Express, Next.js route handler 또는 다른 런타임에서도 동일합니다.
bun add better-auth hono
// auth.ts
import { betterAuth } from 'better-auth';
import { bearer } from 'better-auth/plugins';
import { db } from './db'; // your Drizzle, Prisma, Kysely or pg instance
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL, // https://auth.example.com
database: db,
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_WEB_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
apple: {
clientId: process.env.APPLE_SERVICE_ID as string,
clientSecret: process.env.APPLE_CLIENT_SECRET as string,
appBundleIdentifier: process.env.APPLE_BUNDLE_ID as string, // com.example.app
},
},
trustedOrigins: [
'capacitor://localhost', // iOS WebView
'https://localhost', // Android WebView (androidScheme https, the default)
'https://app.example.com', // your web build
'https://appleid.apple.com',
],
plugins: [bearer()],
});
appBundleIdentifier Apple에서는 이 부분이 중요합니다. native iOS 시트의 token은 bundle ID를 audience로 포함하는 반면, web 및 Android token은 Services ID를 포함합니다. Better Auth는 이 필드를 사용하여 두 방식 모두 수용합니다.
핸들러를 마운트하고 앱 오리진에 대해 CORS를 허용하세요. 다음을 set-auth-token 헤더에 노출해야 클라이언트가 bearer token을 확인할 수 있습니다:
// server.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { auth } from './auth';
const app = new Hono();
app.use(
'/api/auth/*',
cors({
origin: ['capacitor://localhost', 'https://localhost', 'https://app.example.com'],
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
exposeHeaders: ['set-auth-token'],
credentials: true,
}),
);
app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw));
export default app;
실행 bunx @better-auth/cli migrate (또는 generate ORM용)을 사용하여 테이블을 생성합니다.
2단계: 앱에서 인증 클라이언트 생성
bun add better-auth @capgo/capacitor-native-biometric
bunx cap sync
세션 token을 Keychain 또는 Keystore에 저장하고, 클라이언트가 동기적으로 읽을 수 있도록 메모리에 복사본을 유지하세요:
// auth-client.ts
import { createAuthClient } from 'better-auth/client';
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
const TOKEN_KEY = 'session.token';
let sessionToken = '';
export async function loadSessionToken() {
try {
const { value } = await NativeBiometric.getData({ key: TOKEN_KEY });
sessionToken = value;
} catch {
sessionToken = '';
}
}
async function saveSessionToken(token: string) {
sessionToken = token;
if (token) {
await NativeBiometric.setData({ key: TOKEN_KEY, value: token });
} else {
await NativeBiometric.deleteData({ key: TOKEN_KEY }).catch(() => {});
}
}
export const authClient = createAuthClient({
baseURL: 'https://auth.example.com',
fetchOptions: {
auth: {
type: 'Bearer',
token: () => sessionToken,
},
onSuccess: async (ctx) => {
const token = ctx.response.headers.get('set-auth-token');
if (token) await saveSessionToken(token);
},
},
});
export async function clearSession() {
await saveSessionToken('');
}
호출하세요 await loadSessionToken() 앱이 렌더링되기 전에, authClient.getSession() 사용자 정보를 복구하려면:
import { authClient, loadSessionToken } from './auth-client';
await loadSessionToken();
const { data: session } = await authClient.getSession();
if (session) {
console.log('Signed in as', session.user.email);
}
React, Vue, Svelte 또는 Solid를 사용하는 경우, createAuthClient 해당 서브패스에서 import 하세요 (better-auth/react, better-auth/vue, …)를 통해 useSession hook을 가져오세요. fetchOptions 동일하게 유지됩니다. setData 및 getData 메서드는 네이티브 전용입니다. 웹에서는 쿠키나 sessionStorage 사용 Capacitor.isNativePlatform().
3단계: 이메일과 비밀번호
플러그인이 필요 없습니다. 일반 HTTP 호출입니다.
import { authClient, clearSession } from './auth-client';
export async function signUp(email: string, password: string, name: string) {
const { data, error } = await authClient.signUp.email({ email, password, name });
if (error) throw new Error(error.message);
return data;
}
export async function signIn(email: string, password: string) {
const { data, error } = await authClient.signIn.email({ email, password });
if (error) throw new Error(error.message);
return data;
}
export async function signOut() {
await authClient.signOut();
await clearSession();
}
이메일 인증 및 비밀번호 재설정의 경우, Better Auth는 URL을 여는 링크를 보냅니다. 링크를 callbackURL iOS Universal Link 및 Android App Link로도 설정된 도메인 페이지로 지정하세요. 그러면 링크가 휴대폰에서는 앱을, 그 외에서는 웹사이트를 엽니다.
4단계: 네이티브 Google 로그인
다음에 설명된 대로 Google을 구성하세요 Capacitor를 사용하여 Google로 로그인하는 방법, 그런 다음:
import { SocialLogin } from '@capgo/capacitor-social-login';
import { authClient } from './auth-client';
await SocialLogin.initialize({
google: {
webClientId: 'WEB_CLIENT_ID.apps.googleusercontent.com',
iOSClientId: 'IOS_CLIENT_ID.apps.googleusercontent.com',
mode: 'online', // Better Auth needs the idToken, offline mode only returns a code
},
});
export async function signInWithGoogle() {
const { result } = await SocialLogin.login({ provider: 'google', options: {} });
if (result.responseType !== 'online' || !result.idToken) {
throw new Error('Google did not return an ID token');
}
const { data, error } = await authClient.signIn.social({
provider: 'google',
idToken: {
token: result.idToken,
accessToken: result.accessToken?.token,
},
});
if (error) throw new Error(error.message);
return data;
}
Better Auth가 Google 키와 설정된 client ID를 통해 토큰을 검증하고, 사용자를 생성 또는 연결한 뒤 세션을 반환합니다. Bearer 토큰은 set-auth-token header에 포함되어 전달되며, onSuccess hook에 저장됩니다.
5단계: Native Apple 로그인
Apple은 다음 문서를 참고해 설정하세요: Capacitor를 사용해 Apple로 로그인하는 방법. nonce를 생성해 Apple에 전달하고, 같은 값을 Better Auth로 보내세요:
import { SocialLogin } from '@capgo/capacitor-social-login';
import { authClient } from './auth-client';
export async function signInWithApple() {
const nonce = crypto.randomUUID();
const { result } = await SocialLogin.login({
provider: 'apple',
options: { scopes: ['email', 'name'], nonce },
});
if (!result.idToken) throw new Error('Apple did not return an ID token');
const { data, error } = await authClient.signIn.social({
provider: 'apple',
idToken: {
token: result.idToken,
nonce,
accessToken: result.accessToken?.token,
},
});
if (error) throw new Error(error.message);
return data;
}
Apple은 첫 인증 시에만 사용자 이름을 공유합니다. 사용자 레코드에 이름을 저장하려면 첫 로그인 직후에 다음을 사용해 프로필을 업데이트하세요. result.profile.givenName 및 familyName.
Facebook도 같은 방식을 따릅니다. Limited Login을 사용하는 iOS에서는 idToken, 다른 곳에서는 액세스 토큰을 전달합니다. Better Auth 연동 페이지 에서 Facebook 전체 예제를 확인할 수 있습니다.
6단계: Okta, Auth0, Entra ID 및 기타 OIDC 공급자
Better Auth의 genericOAuth 플러그인은 Auth0, Okta, Keycloak, Microsoft Entra ID용 헬퍼를 제공하며, 모든 OIDC 서버에 대한 수동 설정도 지원합니다. 웹 빌드에서는, authClient.signIn.oauth2({ providerId }) 일반 사이트와 동일하게 작동합니다.
iOS 및 Android에서는 공급자 로그인 페이지가 WebView 안에서 로드되면 안 되므로 시스템 브라우저가 필요합니다. 작동하는 패턴은 다음과 같습니다:
- 앱은 시스템 브라우저에서 Better Auth 로그인 URL을 엽니다 (예:
SocialLogin.openSecureWindow또는 인앱 브라우저 플러그인 사용). - Better Auth가 공급자와 OAuth 흐름을 진행하고 서버에 세션을 생성합니다.
- 콜백 페이지에서 해당 세션을 수명이 짧은 일회용 토큰으로 변환합니다(Better Auth에는
oneTimeToken) 플러그인을 사용하고 다음과 같은 딥 링크로 리다이렉트합니다.com.example.app://auth?token=.... - 앱이 auth 클라이언트로 일회용 토큰을 검증하고 일반 Bearer 세션을 받습니다.
ID 토큰 핸드오프보다 신경 쓸 부분이 더 많습니다. Better Auth의 사용자 데이터베이스는 필요 없고 엔터프라이즈 SSO만 필요하다면 제공자에 직접 로그인하는 편이 더 간단합니다. 다음을 참고하세요: Okta, Auth0) 및 Entra ID 가이드.
7단계: 세션으로 API 보호
Better Auth 서버 API를 사용하면 자체 API에서 Bearer 토큰으로 세션을 읽을 수 있습니다:
import { Hono } from 'hono';
import { auth } from './auth';
const api = new Hono();
api.get('/me/orders', async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) return c.json({ error: 'unauthorized' }, 401);
return c.json(await listOrders(session.user.id));
});
Bearer 플러그인을 사용하면 getSession 허용 Authorization: Bearer <token> 쿠키를 허용하는 것과 동일한 방식으로.
문제 해결
| 문제 | 원인 | 해결 방법 |
|---|---|---|
Invalid origin 또는 로그인 시 403 오류 |
Capacitor origin을 신뢰할 수 없음 | 추가 capacitor://localhost 및 https://localhost 대상 trustedOrigins |
| WebView 콘솔의 CORS 에러 | 서버 CORS에 앱 origin이 나열되지 않음 | 이들을 CORS 미들웨어에 추가하고 허용하세요 Authorization |
로그인은 성공하지만 getSession 반환합니다 null |
도메인 간 쿠키에 의존 | bearer 플러그인을 사용하여 토큰을 전송하세요 |
set-auth-token 항상 null |
헤더가 노출되지 않음 | 추가 exposeHeaders: ['set-auth-token'] CORS에 |
Google: disallowed_useragent |
WebView에서 OAuth 페이지 로드됨 | 네이티브 sign-in을 사용하고 idToken 핸드오프 |
| Google ID 토큰이 거부됨 | Audience가 설정된 client ID와 일치하지 않음 | 토큰 디코딩: SocialLogin.decodeIdToken 그리고 비교 aud 포함 clientId |
| iOS에서 Apple ID 토큰이 거부됨 | Audience는 bundle ID입니다 | 설정 appBundleIdentifier Apple provider에서 |
| Apple nonce 오류 | Apple과 Better Auth에 서로 다른 nonce가 전송됨 | 한 번 생성하여 동일한 값 재사용 |
| 앱 재시작 시 세션 유실 | 토큰이 메모리에만 유지됨 | 다음 방식으로 영구 저장: setData 저장 후 첫 요청 전에 로드 |
모바일 쪽 배포하기
인증 변경은 네이티브 영역이 아닌 경우가 많습니다. 신뢰할 수 있는 Origin과 Provider는 서버 설정이며, 클라이언트 code는 TypeScript이므로 대부분의 수정 사항은 스토어 출시 대신 Capgo Live Update 사용하기 배포할 수 있습니다. 네이티브 플러그인을 추가하거나 URL scheme을 변경할 때만 새로운 바이너리가 필요합니다.
관련 가이드
- Capacitor 앱에 인증을 추가하는 방법 Better Auth를 Firebase, Supabase, Neon Auth(관리형 Better Auth) 및 호스팅된 Identity Provider와 비교합니다.
- Capacitor 앱의 생체 인증 문서에서 확인하세요 저장된 세션을 Face ID나 지문으로 잠금 해제할 때 사용합니다.
- Social Login 플러그인 Google, Apple, Facebook의 네이티브 플로우를 위해 사용합니다.