Apple Sign-In 설정 가이드

myapp-backend

업데이트

2026년 4월 26일

개요

Apple Sign-In은 카카오/구글/네이버와 다음 두 가지 측면에서 다르다.

  1. 웹 로그인은 backend가 OAuth proxy 역할을 해야 한다. client_secret JWT를 .p8 private key로 직접 서명해야 하고, Apple은 scope=name email인 경우 response_mode=form_post를 강제하므로 frontend가 응답을 직접 받을 수 없다.
  2. App ID와 Services ID라는 두 개의 식별자가 필요하다.
    • App ID = iOS 앱 Bundle ID (Primary App ID)
    • Services ID = 웹 로그인 전용 식별자 (App ID와 다른 이름이어야 함)

이 문서는 Apple Developer Portal 설정부터 환경변수, 트러블슈팅까지 한 번에 끝낼 수 있도록 정리한 가이드다.

노트

한국 개발자 추가 의무 (2026-01-01부터) App ID 등록·변경 시 Server-to-Server Notification Endpoint를 필수 등록. 참고: https://developer.apple.com/news/?id=j9zukcr6


사전 준비

항목 비고
Apple Developer Program 멤버십 개인 $99/년, 법인 $299/년
본인 도메인 (예: myapp.com) Services ID 등록 시 필요
로컬 HTTPS 터널 (ngrok 등) Web flow 로컬 테스트용. localhost는 Apple이 거부
.p8 안전한 보관소 (1Password 등) Key는 한 번만 다운로드 가능, 분실 시 재발급 불가

1. Team ID 확인 — APPLE_TEAM_ID

Membership details에서 Team ID (10자 영숫자) 복사.

APPLE_TEAM_ID=ABCDE12345

2. App ID 생성 — APPLE_CLIENT_ID

iOS 앱 Bundle ID이자 Sign In with Apple의 Primary App ID 역할.

단계

  1. Identifiers

  2. App IDsApp 선택 → Continue

    certificates-Identifiers-Profiles.png
  3. 입력

    • Description: MyApp iOS App
    • Bundle ID: Explicit 선택 → com.myapp.app
  4. Capabilities 섹션 → Sign In with Apple

    Edit-your-Services-ID-Configuration.png
  5. ContinueRegister

Sign In with Apple Capability 활성화 (★ 자주 빠뜨리는 단계)

방금 만든 App ID를 다시 클릭 → Sign In with AppleEdit (또는 Configure):

  • Enable as a primary App ID 선택 (Group with...이 아님)
  • Save
  • 페이지 우측 상단의 Save 버튼도 별도로 눌러야 적용됨 → 확인 다이얼로그 → Confirm
APPLE_CLIENT_ID=com.myapp.app
경고

App ID와 Services ID는 같은 네임스페이스를 공유한다. App ID로 com.myapp.web을 이미 등록했다면 같은 식별자로 Services ID를 만들 수 없고, 그 반대도 마찬가지.


3. Services ID 생성 — APPLE_SERVICES_ID

웹 로그인 전용. App ID와 다른 식별자여야 함.

단계

  1. Identifiers
  2. Services IDs 선택 → Continue
  3. 입력
    • Description: MyApp Web Login
    • Identifier: com.myapp.web
  4. ContinueRegister

Web Authentication Configuration

방금 만든 Services ID 클릭 → Sign In with Apple ✅ → Configure:

중요

이 다이얼로그가 회색으로 비활성화돼 있다면 2단계의 App ID에서 “Enable as a primary App ID”가 체크되지 않은 상태다. App ID 설정으로 돌아가서 Primary 활성화 + Save 후 다시 시도.

다이얼로그 입력:

  • Primary App ID: 위에서 만든 com.myapp.app 선택

  • Domains and Subdomains (호스트명만, scheme/path 없음):

    api.myapp.com
    delighted-upstroke-settle.ngrok-free.dev
  • Return URLs (https:// + 전체 경로):

    https://api.myapp.com/api/auth/apple/callback/
    https://delighted-upstroke-settle.ngrok-free.dev/api/auth/apple/callback/
  • NextDoneContinueSave

APPLE_SERVICES_ID=com.myapp.web
힌트

도메인 소유 검증: production 도메인(api.myapp.com)은 Apple이 apple-developer-domain-association.txt/.well-known/ 경로에 호스팅하라고 요구할 수 있다. ngrok 무료 도메인은 ngrok 소유라 보통 검증이 면제된다.


4. Sign In with Apple Key 생성 — APPLE_KEY_ID + APPLE_PRIVATE_KEY

단계

  1. Keys
  2. 입력
    • Key Name: MyApp Sign In with Apple Key
    • Sign In with Apple ✅ → 우측 Configure
  3. Choose a Primary App ID: com.myapp.app 선택 → Save
  4. ContinueRegister

⚠️ 발급 직후 화면 (단 한 번만 나타남)

  • Key ID (10자) 복사 → APPLE_KEY_ID
  • Download 버튼 → AuthKey_XXXXXXXXXX.p8 다운로드
  • 1Password 등 안전한 곳에 백업 (재발급 불가)

.p8 → 환경변수 변환

# 줄바꿈을 \n으로 이스케이프해서 한 줄로 변환, 클립보드 복사
awk 'NR==1{printf "%s",$0;next}{printf "\\n%s",$0}END{printf "\\n"}' \
  ~/Downloads/AuthKey_*.p8 | pbcopy

# .env.local 의 APPLE_PRIVATE_KEY="..." 큰따옴표 안에 붙여넣기
APPLE_KEY_ID=ABCDEF1234
APPLE_PRIVATE_KEY=<.p8 파일 내용을 \n으로 이스케이프한 한 줄>

5. Server-to-Server Notification Endpoint 등록

App ID 쪽에 등록 (Services ID 아님).

단계

  1. Identifiers

  2. App ID(com.myapp.app) 클릭

  3. Sign In with Apple 행의 우측 Edit

  4. Server-to-Server Notification Endpoint 입력:

    https://api.myapp.com/api/auth/apple/notifications/
  5. SaveContinueSave

노트

Endpoint 요구사항: HTTPS 필수 / non-200 응답 시 Apple이 재시도. 우리 구현은 어떤 경우에도 200을 반환하므로 OK.

Apple이 보내는 4가지 이벤트

Type 발생 시점 우리 처리
email-enabled Apple proxy 이메일 수신 재활성화 User.can_receive_email = True
email-disabled Apple proxy 이메일 수신 비활성화 User.can_receive_email = False
consent-revoked 사용자가 Apple 로그인 연동 해제 Apple 연동만 unlink + 비활성화
account-delete Apple ID 자체 삭제 Apple 연동만 unlink + 비활성화

정책: 학원 데이터 보존 차원에서 자동 데이터 삭제는 하지 않음. 사용자가 명시적으로 회원탈퇴 요청 시에만 DeleteAccountView가 데이터 정리.


6. 백엔드 환경변수 (.env.local / Railway)

# 5개 + 1개 = 6개
APPLE_CLIENT_ID=com.myapp.app
APPLE_SERVICES_ID=com.myapp.web
APPLE_TEAM_ID=ABCDE12345
APPLE_KEY_ID=ABCDEF1234
APPLE_PRIVATE_KEY=<.p8 파일 내용, \n으로 이스케이프한 한 줄>
APPLE_CALLBACK_URL=https://delighted-upstroke-settle.ngrok-free.dev/api/auth/apple/callback/

서버 재시작 후 검증:

uv run python src/manage.py runserver
open "http://localhost:8000/api/auth/apple/login/?redirect_uri=http://localhost:5173/auth/apple/callback"
# → Apple authorize 페이지로 302 redirect 되면 성공

부록 A: ngrok 설정 (로컬 HTTPS 터널)

Apple Web flow는 HTTPS public URL이 필수http://localhost:8000은 거부된다.

설치 + 인증

brew install ngrok
# 토큰 확인: https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken <YOUR_AUTHTOKEN>

Static Domain (무료 plan에서도 1개 제공)

Domains+ New Domain

경고

무료 plan의 Dev domain은 삭제 불가 (“Dev domains cannot be deleted”). 이름 변경은 Edit으로 가능, 그것도 안 되면 그대로 사용.

터널 시작

# Static Domain으로 8000 포트 노출 (별도 터미널, 켜둔 채로)
ngrok http --domain=delighted-upstroke-settle.ngrok-free.dev 8000

# 인스펙터 (디버깅에 유용)
open http://localhost:4040

Apple Developer Portal에 ngrok 도메인 등록

  1. Services ID → Web Authentication Configuration:
  • Domains: delighted-upstroke-settle.ngrok-free.dev
  • Return URLs: https://delighted-upstroke-settle.ngrok-free.dev/api/auth/apple/callback/

.env.local 갱신

APPLE_CALLBACK_URL=https://delighted-upstroke-settle.ngrok-free.dev/api/auth/apple/callback/

부록 B: 트러블슈팅 모음 (자주 묻는 에러)

B-1. No App ID is available (Services ID 설정에서)

원인: App ID에 Sign In with Apple capability가 활성화 안 됐거나 Primary가 아님.

해결: 2. App ID 단계로 돌아가서: 1. Capabilities → Sign In with Apple ✅ 2. Edit → Enable as a primary App ID ✅ 3. App ID 페이지 우측 상단 Save


B-2. An App ID with Identifier 'X' is not available

원인: 식별자가 글로벌 unique. 누군가(혹은 본인이 예전에) 이미 등록했거나, 같은 이름이 Services ID에 이미 있음 (App ID/Services ID는 네임스페이스 공유).

해결: 다른 식별자로:

용도 권장
App ID com.myapp.app, com.myapp.ios, com.myapp.signin
Services ID com.myapp.web, com.myapp.signin.web, com.myapp.auth

B-3. One or more domains are invalid (Web Authentication Configuration)

원인: Domains과 Return URLs 필드를 혼동.

해결:

필드 형식 예시
Domains and Subdomains 호스트명만 api.myapp.com
Return URLs https:// + 전체 경로 https://api.myapp.com/api/auth/apple/callback/

http://, localhost, IP 주소는 모두 거부됨.


B-4. 503 Service Unavailable on /api/auth/apple/login/

원인: backend의 가드 — APPLE_SERVICES_ID 또는 APPLE_CALLBACK_URL 환경변수 미설정.

해결: .env.local에 6절의 6개 변수 모두 채우고 서버 재시작.


B-5. 404 Not Found on /api/auth/apple/login/

원인: Backend가 옛 버전이거나 마이그레이션 미적용.

해결:

git pull
uv sync
uv run python src/manage.py migrate accounts
uv run python src/manage.py runserver

B-6. Apple 로그인 마지막에 400 Bad Request (frontend)

POST /api/auth/apple/callback/에 대한 400. 가능성 3가지:

  1. Frontend dev mode 이중 실행 (가장 흔함) React StrictMode / Svelte HMR이 effect를 두 번 호출 → 1차는 200, 2차는 400 (exchange_code는 1회용). localStorage에 토큰 있으면 사실 로그인은 성공.

  2. Frontend가 잘못된 키로 보냄 URL의 ?exchange_code=X를 그대로 {code: X}로 POST. 정확한 키는 exchange_code (다른 OAuth와 다름).

  3. exchange_code 만료 (2분 TTL) 가능성 낮음.

frontend /auth/apple/callback 라우트 처리 예:

const url = new URL(window.location.href);
const status = url.searchParams.get('status');

if (status === 'error') {
  goto(`/login?error=${url.searchParams.get('message')}`);
} else if (status === 'nickname_required') {
  const tempKey = url.searchParams.get('temp_key');
  goto(`/signup/nickname?provider=apple&temp_key=${tempKey}`);
} else if (status === 'success') {
  const exchangeCode = url.searchParams.get('exchange_code');
  const res = await fetch(`${API_BASE}/api/auth/apple/callback/`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ exchange_code: exchangeCode }),
  });
  const data = await res.json();
  // data.data.tokens.access / refresh 저장
}

B-7. invalid_client (Apple authorize 페이지에서)

원인: Services ID 식별자 오타, 또는 Apple Developer Portal에 미등록.

해결: .env.localAPPLE_SERVICES_ID 값과 Portal 식별자 일치 확인.


B-8. invalid_request redirect_uri (Apple authorize)

원인: Return URL이 Services ID에 등록되지 않음. trailing slash 불일치도 흔함.

해결: Return URL은 정확히 .../api/auth/apple/callback/ (끝에 /)로 등록.


B-9. invalid_grant (token 교환 시)

원인: client_secret JWT 서명이 틀림. APPLE_TEAM_ID /APPLE_KEY_ID / APPLE_PRIVATE_KEY 중 하나라도 잘못됨.

해결: 4. Key 다시 발급 + 4개 값 재설정.


B-10. ngrok Dev domain 삭제 안 됨

원인: 무료 plan의 Dev domain은 영구. “Dev domains cannot be deleted”.

해결: - A: Dashboard에서 Edit → 이름만 변경 - B: 그냥 그대로 사용 (어차피 기능은 동일) - C: Bring Your Own (유료, 본인 도메인) — dev-api.myapp.com CNAME 등록


부록 C: 백엔드 구현 요약

자세한 코드는 src/accounts/apple.py, src/accounts/views.py, src/accounts/tasks.py 참고.

Endpoint

Method & Path 역할
GET /api/auth/apple/login/?redirect_uri=... Web flow 시작 → Apple authorize 302
POST /api/auth/apple/callback/ 4가지 진입점 통합 (form_post / native / temp_key / exchange_code)
POST /api/auth/apple/notifications/ Server-to-Server Notification 수신 (항상 200)

Web flow 시퀀스

[1] frontend → GET  /api/auth/apple/login/?redirect_uri={frontend_url}
                    │
                    └─ 302 → https://appleid.apple.com/auth/authorize?...&state={S}

[2] Apple → POST /api/auth/apple/callback/  (form_post: state, code, id_token, user)
                    ├─ id_token 검증 (없으면 code 교환)
                    ├─ 기존 사용자 → exchange_code 발급 → 302 → {frontend_url}?status=success&exchange_code=X
                    ├─ 신규 사용자 → temp_key 발급 → 302 → {frontend_url}?status=nickname_required&temp_key=X&email=...
                    └─ 에러 → 302 → {frontend_url}?status=error&message=...

[3] frontend → POST /api/auth/apple/callback/  {exchange_code: X}
                    └─ {tokens: {access, refresh}, user, is_new_user: false}

[4] frontend → POST /api/auth/apple/callback/  {temp_key: X, nickname: "..."}
                    └─ 신규 가입 완료 + JWT 발급

iOS 네이티브는 [3] 대신 {identity_token: ...}를 직접 POST.

핵심 보안 가드

  • redirect_uri allowlist: FRONTEND_URL / ADMIN_CONSOLE_URL prefix만 통과 (open redirect 방지)
  • state: 32바이트 URL-safe 랜덤, 1회 소비 (CSRF + 위조 차단)
  • exchange_code: 32바이트, 2분 TTL, 1회 소비 (URL에 토큰 미노출)
  • jti: Apple Notification 24시간 멱등성 (Apple 재시도 방지)
  • JWKS 캐싱: Redis 12h, kid 미스 시 자동 강제 갱신 (Apple 키 로테이션 대응)

환경별 설정 위치

파일 설정
src/config/settings/base.py 모든 Apple 변수 default = ""
src/config/settings/local.py env 로딩 + APPLE_CALLBACK_URL default http://localhost:8000/...
src/config/settings/prod.py env 로딩 + APPLE_CALLBACK_URL default https://api.myapp.com/...
.env.local 실제 값 (★ git ignore)

마이그레이션

uv run python src/manage.py migrate accounts
# Applying accounts.0007_apple_provider_and_email_consent... OK

추가된 변경: - Account.OAuthProvider.APPLE (choice) - Account.can_receive_email (BooleanField, default=True)


부록 D: 명령어 모음 (치트시트)

# ngrok 시작
ngrok http --domain=<your-static-domain>.ngrok-free.dev 8000

# Django 개발 서버
uv run python src/manage.py runserver

# 마이그레이션 적용
uv run python src/manage.py migrate accounts

# Apple 관련 테스트
DATABASE_URL=sqlite:////tmp/test_apple.sqlite3 \
  DJANGO_SETTINGS_MODULE=config.settings.local \
  uv run pytest tests/accounts/test_apple_oauth.py tests/accounts/test_apple_notifications.py --no-cov

# Apple authorize 진입 확인
open "http://localhost:8000/api/auth/apple/login/?redirect_uri=http://localhost:5173/auth/apple/callback"

# .p8 → 환경변수 변환
awk 'NR==1{printf "%s",$0;next}{printf "\\n%s",$0}END{printf "\\n"}' \
  ~/Downloads/AuthKey_*.p8 | pbcopy

참고 링크

맨 위로