본문 바로가기

Capacitor로 Sign In with Apple 로그인하는 방법 (2026)

iOS, Android, 웹에서 Capacitor 8 앱에 Sign in with Apple 추가하기: Apple Developer 설정, code plugin, 백엔드 토큰 검증, 자주 발생하는 오류.

Capacitor 앱에서 Capgo Social Login 플러그인으로 실행되는 Sign in with Apple 버튼

Capacitor 앱에 Sign in with Apple을 추가하려면 다음을 설치하세요 @capgo/capacitor-social-login, App ID와 Xcode에서 Sign in with Apple 기능을 활성화한 다음, 다음을 호출하세요 SocialLogin.initialize({ apple: {} }), 그런 다음 호출 SocialLogin.login({ provider: 'apple' }). iOS에서는 네이티브 Apple 시트가 열리고 identity token이 반환됩니다. Android와 웹은 Services ID와 HTTPS 리다이렉트 URL이 필요한 Apple의 웹 OAuth 플로우를 사용합니다.

이 가이드는 Apple Developer 설정, iOS/Android/웹용 클라이언트 code, 백엔드에서의 토큰 검증, 그리고 가장 자주 마주치게 될 오류를 다룹니다. 2026년 4월부터 App Store Connect가 업로드에 요구해 온 Capacitor 8과 Xcode 26을 대상으로 합니다.

시작하기 전에 필요한 것

요구 사항 이유
유료 Apple Developer Program 멤버십 App ID, Services ID, Sign in with Apple 키는 멤버만 사용할 수 있습니다
Xcode 26이 설치된 Mac 2026년 iOS 앱 빌드 및 업로드에 필요한 것
Capacitor 8 프로젝트 플러그인 v8은 Capacitor 8을 대상으로 합니다
직접 관리하는 HTTPS 도메인 Android와 웹에만 해당. Apple은 거부합니다 localhost 그리고 일반 HTTP 리턴 URL
백엔드 엔드포인트 토큰을 항상 검증합니다. Android에서는 Apple의 콜백도 수신합니다.

서명 인증서가 아직 없다면, iOS 인증서 생성기 Mac 키체인 설정 과정 없이 이를 생성합니다.

플랫폼별 Sign in with Apple 동작 방식

Apple은 자체 플랫폼용 네이티브 SDK만 제공하므로 플로우가 다릅니다:

플랫폼 메커니즘 사용되는 Client ID 로그인에 필요한 백엔드
iOS 네이티브 ASAuthorizationAppleIDProvider 시트 앱 번들 ID 아니요
Android Chrome Custom Tab에서 Apple 웹 OAuth를 실행하고, Apple이 서버로 게시하며, 서버가 앱으로 리디렉션합니다 Services ID 지원
웹 Apple JS 팝업 Services ID 아니요 (단, 토큰은 검증하세요)

identity token의 audience (aud) 값은 클라이언트 ID를 따릅니다. iOS에서 발급된 토큰에는 번들 ID가, Android와 웹에서 발급된 토큰에는 Services ID가 포함됩니다. 백엔드에서는 두 가지 모두를 허용해야 합니다.

1단계: Apple Developer 계정 설정

App ID에서 기능 활성화

  1. 열기 인증서, 식별자 및 프로필 를 선택한 후 식별자.
  2. Capacitor에 해당하는 App ID를 엽니다 appId (예를 들어 com.example.app). 존재하지 않으면 생성하세요.
  3. 아래 기능, 체크하고 Apple로 로그인 그리고 저장하세요. 여러 앱을 묶지 않는 한 기본 App ID로 두세요.

기존 App ID의 capabilities를 변경했다면 해당 App ID를 사용하는 provisioning profiles를 다시 생성하세요. 기존 프로필에는 새로운 entitlement가 포함되지 않습니다.

Services ID 생성 (Android 및 웹 전용)

  1. 다음에서 식별자, 클릭 + 다음을 선택하세요: 서비스 ID.
  2. “Example Web Login”과 같은 설명을 사용하고, identifier는 예를 들어 com.example.app.signin. 이 값이 clientId Android와 웹에서.
  3. 새 Services ID를 열고 체크한 다음 Apple로 로그인클릭하세요. 구성하세요:.
  4. App ID를 기본 App ID로 선택하세요.
  5. 아래에 도메인을 추가하세요 도메인 및 서브도메인 (스킴 없이, 예: auth.example.com).
  6. 아래에 리턴 URL을 추가하세요 리턴 URL 전체 URL 포함 https:// 경로, 예를 들어 https://auth.example.com/apple/callback. 웹 로그인의 경우, 로그인을 호출하는 페이지 URL을 후행 슬래시가 있는 경우와 없는 경우 모두 추가하세요.

Sign in with Apple 키 생성 (Android 및 서버 토큰 교환)

  1. 다음으로 이동: 키, 클릭 +, 체크하고 Apple로 로그인클릭하세요. 구성하세요: 기본 App ID를 선택하려면
  2. 키를 등록하고 다운로드하세요 .p8 파일입니다. Apple은 이를 한 번만 다운로드할 수 있도록 합니다.
  3. 다음 항목을 기록하세요: Key ID 그리고 팀 ID (개발자 포털 우측 상단 및 Membership 섹션에 표시됨).

서버는 Apple의 토큰 엔드포인트로 전송하는 client_secret JWT에 서명하는 데 이 키를 사용합니다.

이메일을 보내는 경우 이메일 소스를 등록하세요

사용자는 이메일 숨기기을(를) 선택할 수 있으며, 이렇게 하면 @privaterelay.appleid.com 주소입니다. Apple은 등록한 도메인과 주소에서 온 메일만 전달합니다 Services > Sign in with Apple for Email Communication. 이 단계를 건너뛰면 비밀번호 재설정 및 영수증 이메일이 조용히 사라집니다.

2단계: 플러그인 설치

bun add @capgo/capacitor-social-login
bunx cap sync

플러그인은 여러 제공자를 번들링합니다. Apple(그리고 아마도 Google)만 사용한다면, 다른 제공자를 다음에서 끄세요 capacitor.config.ts SDK가 배포되지 않도록 하려면:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.app',
  appName: 'Example',
  webDir: 'dist',
  plugins: {
    SocialLogin: {
      providers: {
        apple: true,
        google: false,
        facebook: false,
        twitter: false,
      },
    },
  },
};

export default config;

실행 bunx cap sync 이 블록을 변경한 후 다시 실행하세요. 비활성화된 제공자는 런타임에서 사용할 수 없으므로, 호출 중인 제공자는 비활성화하지 마세요. Facebook을 제거하면 Google Play Console에서 발생하는 AD_ID 권한 경고도 함께 제거됩니다. 이는 SDK이 트리거하는 것입니다.

3단계: iOS 설정

  1. 실행 bunx cap open ios.
  2. 다음을 선택하세요: 앱 열고 Signing & Capabilities, 클릭 + Capability 추가, 그리고 추가 Apple로 로그인.
  3. Bundle identifier가 설정한 App ID와 일치하는지 확인하세요.

Xcode가 생성합니다 com.apple.developer.applesignin 앱으로 보내도록 App.entitlements. CI에서 빌드하거나 로컬에서 빌드하는 경우 Capgo 빌드, 해당 entitlements 파일을 커밋하세요. 그렇지 않으면 클라우드 빌드에서 다음 오류로 실패하는 앱이 만들어집니다: ASAuthorizationError code 1000.

아니요 Info.plist URL scheme 또는 AppDelegate iOS에서 Apple 관련 변경이 필요합니다.

4단계: Android 설정

Apple에는 Android용 SDK이 없습니다. 이 플러그인은 Custom Tab에서 Services ID를 사용해 Apple의 인가 페이지를 엽니다. Apple은 결과를 form POST로 HTTPS 리턴 URL에 전송합니다. 서버는 code을 교환한 다음, 토큰을 담아 앱을 다시 여는 딥 링크로 리다이렉트합니다.

다음에서 android/app/src/main/AndroidManifest.xml, 안에 MainActivity <activity> 요소:

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="com.example.app" android:host="apple-login" />
</intent-filter>

intent를 플러그인으로 전달

편집 MainActivity.java 딥 링크가 플러그인에 전달되도록:

package com.example.app;

import android.content.Intent;
import android.net.Uri;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginHandle;
import ee.forgr.capacitor.social.login.SocialLoginPlugin;

public class MainActivity extends BridgeActivity {

    @Override
    protected void onNewIntent(Intent intent) {
        Uri data = intent.getData();
        if (Intent.ACTION_VIEW.equals(intent.getAction()) && data != null
                && "apple-login".equals(data.getHost())) {
            PluginHandle handle = getBridge().getPlugin("SocialLogin");
            if (handle != null) {
                Plugin plugin = handle.getInstance();
                if (plugin instanceof SocialLoginPlugin) {
                    ((SocialLoginPlugin) plugin).handleAppleLoginIntent(intent);
                    return;
                }
            }
        }
        super.onNewIntent(intent);
    }
}

호스트 기준으로 필터링하면 다른 딥 링크(비밀번호 재설정, 마케팅 링크)가 계속 작동합니다.

콜백 엔드포인트

Apple이 POST로 전송 code, id_token, state, 그리고 첫 로그인 시 user JSON 문자열을 return URL로 보냅니다. 엔드포인트는 code를 교환한 뒤, 다음을 담아 앱으로 리다이렉트해야 합니다: success=true 플러그인이 파싱하는 규약인 토큰들도 포함됩니다. Hono로 작성한 최소 예시는 다음과 같습니다. jose:

import { Hono } from 'hono';
import { SignJWT, importPKCS8, createRemoteJWKSet, jwtVerify } from 'jose';
import { saveAppleRefreshToken } from './db'; // your own persistence layer

const app = new Hono();

const TEAM_ID = process.env.APPLE_TEAM_ID!;
const KEY_ID = process.env.APPLE_KEY_ID!;
const SERVICE_ID = process.env.APPLE_SERVICE_ID!; // com.example.app.signin
const PRIVATE_KEY = process.env.APPLE_PRIVATE_KEY!; // contents of the .p8 file
const APP_REDIRECT = 'com.example.app://apple-login';
const APPLE_JWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));

async function appleClientSecret() {
  const key = await importPKCS8(PRIVATE_KEY, 'ES256');
  return new SignJWT({})
    .setProtectedHeader({ alg: 'ES256', kid: KEY_ID })
    .setIssuer(TEAM_ID)
    .setSubject(SERVICE_ID)
    .setAudience('https://appleid.apple.com')
    .setIssuedAt()
    .setExpirationTime('5m')
    .sign(key);
}

app.post('/apple/callback', async (c) => {
  const form = await c.req.parseBody();
  const code = String(form.code ?? '');
  if (!code) {
    return c.redirect(`${APP_REDIRECT}?success=false`);
  }

  // `form.user` is only present on the first authorization. Store it now.

  const res = await fetch('https://appleid.apple.com/auth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: SERVICE_ID,
      client_secret: await appleClientSecret(),
      code,
      grant_type: 'authorization_code',
      redirect_uri: 'https://auth.example.com/apple/callback',
    }),
  });
  if (!res.ok) {
    return c.redirect(`${APP_REDIRECT}?success=false`);
  }
  const tokens = await res.json();

  // Verify the identity token, then keep the long-lived refresh token on the server.
  // Only the short-lived tokens go back to the app in the deep link.
  const { payload } = await jwtVerify(tokens.id_token, APPLE_JWKS, {
    issuer: 'https://appleid.apple.com',
    audience: SERVICE_ID,
  });
  if (tokens.refresh_token) {
    await saveAppleRefreshToken(String(payload.sub), tokens.refresh_token);
  }
  const params = new URLSearchParams({
    success: 'true',
    access_token: tokens.access_token,
    id_token: tokens.id_token,
  });
  return c.redirect(`${APP_REDIRECT}?${params}`);
});

export default app;

플러그인은 딥 링크 쿼리 스트링에서 토큰을 읽어오므로, 여기에 넣은 내용은 로그에 남거나 같은 커스텀 스킴을 등록한 다른 앱이 읽을 수 있습니다. 수명이 짧은 access_token 및 id_token, 리프레시 토큰은 절대 포함하지 마세요. 신뢰하기 전에 id_token 서명과 nonce 를 서버에서 검증하고, 앱에 고유한 스킴(리버스 번들 ID)을 사용하세요.

이 client_secret JWT는 최대 6개월까지 유효할 수 있지만, 요청마다 짧은 JWT를 생성하면 만료로 인한 장애를 피할 수 있습니다. .p8 파일은 시크릿 매니저에 보관하고, 앱 번들에는 절대 넣지 마세요.

5단계: 웹 설정

웹 빌드는 Services ID와 Apple JS를 사용합니다. 로그인을 트리거하는 정확한 페이지 URL을 Services ID의 Return URLs에 추가하세요. Apple은 localhost를 허용하지 않으므로, 배포된 프리뷰 도메인에서 또는 실제 호스트명이 있는 터널을 통해 웹 로그인을 테스트하세요.

6단계: 초기화 및 로그인

앱 시작 시 한 번 초기화하세요. 다음을 전달하세요: redirectUrl Android와 웹에서만 사용됩니다. iOS에서는 비어 있지 않은 redirectUrl 를 설정하면 플러그인이 해당 URL로 결과를 POST하게 되는데, 네이티브 플로우에서는 원하지 않는 동작입니다.

import { Capacitor } from '@capacitor/core';
import { SocialLogin } from '@capgo/capacitor-social-login';

const platform = Capacitor.getPlatform();

export async function initAuth() {
  await SocialLogin.initialize({
    apple: {
      clientId: platform === 'ios' ? 'com.example.app' : 'com.example.app.signin',
      redirectUrl:
        platform === 'android'
          ? 'https://auth.example.com/apple/callback'
          : platform === 'web'
            ? 'https://app.example.com/login'
            : '',
    },
  });
}

그다음 버튼으로 로그인을 트리거하세요. 서버가 재전송된 토큰을 거부할 수 있도록 랜덤 nonce를 사용하세요:

import { SocialLogin } from '@capgo/capacitor-social-login';

export async function signInWithApple() {
  const nonce = crypto.randomUUID();

  try {
    const { result } = await SocialLogin.login({
      provider: 'apple',
      options: {
        scopes: ['email', 'name'],
        nonce,
      },
    });

    if (!result.idToken) {
      throw new Error('Apple did not return an identity token');
    }

    // Send to your backend. Name and email are only present on the first login.
    const response = await fetch('https://api.example.com/auth/apple', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        idToken: result.idToken,
        nonce,
        givenName: result.profile.givenName,
        familyName: result.profile.familyName,
      }),
    });
    return response.json();
  } catch (error: any) {
    if (error?.code === 'USER_CANCELLED') {
      return null; // user closed the sheet, not a failure
    }
    throw error;
  }
}

응답으로 다음을 받습니다:

필드 내용
result.idToken Apple이 서명한 JWT, 즉 서버에서 검증하는 값
result.profile.user Apple의 고정 사용자 ID (다음과 동일: sub claim)
result.profile.email 권한 허용 시 제공되는 이메일 또는 Private Relay 주소
result.profile.givenName / familyName 최초 인증 시에만 제공됨
result.accessToken 기본 설정에서 이 값은 실제 access token이 아닌 authorization code을 담습니다
result.authorizationCode 초기화할 때 포함됨 useProperTokenExchange: true

서버에서 Apple refresh token이 필요한 경우(예: 사용자가 계정을 삭제할 때 revoke endpoint를 호출하는 경우) 다음을 설정하세요 useProperTokenExchange: true 플러그인의 apple config를 설정하고 교환하세요 authorizationCode 서버에서.

7단계: 백엔드에서 identity token 검증

클라이언트가 보내는 profile 필드를 절대 신뢰하지 마세요. Apple의 public key로 JWT 서명을 검증하고 다음 claim을 확인하세요:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const appleKeys = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));

export async function verifyAppleToken(idToken: string, expectedNonce: string) {
  const { payload } = await jwtVerify(idToken, appleKeys, {
    issuer: 'https://appleid.apple.com',
    audience: ['com.example.app', 'com.example.app.signin'], // bundle ID + Services ID
  });

  if (payload.nonce !== expectedNonce) {
    throw new Error('Nonce mismatch');
  }

  return {
    appleUserId: payload.sub as string,
    email: payload.email as string | undefined,
    emailVerified: payload.email_verified === true || payload.email_verified === 'true',
  };
}

사용자 레코드의 키를 다음으로 지정 sub, 이메일이 아닙니다. 이메일은 릴레이 주소일 수 있으며, 사용자는 공유를 중단할 수 있습니다. 인증 후 자체 세션 토큰을 발급하세요. Supabase 또는 Firebase를 사용하는 경우, 토큰을 대신 검증해 주므로 참고하세요. Capacitor Social Login을 사용하는 Supabase.

기억해야 할 App Store 규칙

  • 지침 4.8: Google, Facebook 또는 다른 제3자 로그인을 주요 계정 시스템으로 제공하는 경우, 동등한 수준의 프라이버시 중심 옵션도 제공해야 합니다. Sign in with Apple이 해당 조건을 충족합니다.
  • 가이드라인 5.1.1(v): 사용자가 계정을 생성할 수 있는 앱은 앱 내에서 계정 삭제를 허용해야 합니다. Sign in with Apple 사용자의 경우, Apple의 https://appleid.apple.com/auth/revoke 삭제 시 엔드포인트. 참고 계정 삭제 가이드.
  • 버튼 디자인: Apple이 승인한 버튼 스타일을 사용하고, 다른 로그인 버튼과同等하거나 더 눈에 띄게 만드세요.

문제 해결

증상 예상 원인 해결 방법
ASAuthorizationError iOS에서 code 1000 빌드 대상에 Capability 또는 entitlement 누락 Xcode에서 Sign in with Apple 추가, 커밋 App.entitlements, 프로필 재생성
Code 1001 (iOS) 사용자가 취소함 플러그인이 다음으로 거부됩니다 USER_CANCELLED, 오류가 아닌 것으로 처리
invalid_client Android 또는 웹에서 잘못된 clientId (Services ID 대신 bundle ID 사용) 또는 잘못된 client_secret Services ID 사용, Team ID, Key ID 및 키 내용 확인
invalid_request: Invalid redirect_uri Return URL이 정확히 등록되지 않음 완전한 https:// URL을 Services ID에 추가하고, 끝의 슬래시 일치 확인
Android 로그인 후 앱이 다시 열리지 않음 Intent 필터의 scheme 또는 host가 서버 리디렉션과 다릅니다 비교 com.example.app://apple-login 문자 단위
apple.android.redirectUrl is null or empty 아니요 redirectUrl Android에서 Android에서 초기화할 때 HTTPS 콜백 URL을 전달하세요
이름과 이메일은 null 최초 인증이 아님 데이터베이스에서 읽어오세요. 다시 테스트하려면 기기 설정에서 앱 연결을 해제하세요
백엔드에서 토큰이 거부됨: aud 불일치 서버는 audience를 하나만 허용 Bundle ID와 Services ID 모두 허용

릴리스 후 수정 배포

Most auth bugs live in TypeScript: a wrong client ID per platform, a missing nonce, bad error handling. Those can be shipped to users the same day with Capgo Live Update 사용하기 App Review를 기다리지 않고. entitlements, Android manifest 또는 MainActivity 은 네이티브 변경 사항이며 새로운 스토어 빌드가 필요합니다.

다음 단계

Capacitor 앱용 Live updates

웹 레이어 버그가 라이브 상태라면, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상 리뷰 절차를 그대로 따르는 반면, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 정말 프로페셔널한 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.