Recode Log

  • tech-blog
  • apps
  • device
  • features

Copyright © [WebKBS]. All rights reserved.

·이용 안내
  • tech-blog
  • apps
  • device
  • features
  1. Home
  2. tech-blog
  3. React 웹에서 Apple 로그인 구현하기 - Sign in with Apple JS와 Node.js 검증

React 웹에서 Apple 로그인 구현하기 - Sign in with Apple JS와 Node.js 검증

React와 Vite에서 Sign in with Apple JS를 연결하고, Node.js와 TypeScript에서 authorization code와 id_token을 검증하는 과정을 단계별로 정리합니다.

  • react 19.2.8
  • vite 8.2.0
  • typescript 6.0.2
  • jose 6.x
2026년 8월 12일

소개

이번에는 React 웹 애플리케이션에 Apple로 로그인 기능을 구현해 보겠습니다.

Apple 로그인은 React에 버튼 하나만 추가한다고 끝나지 않습니다. Apple Developer에서 App ID와 Services ID를 연결하고, 웹 도메인과 Return URL을 등록한 뒤, 백엔드에서 Apple이 전달한 인증 결과를 검증해야 합니다.

이 글에서는 다음 과정을 순서대로 진행합니다.

  1. React에서 Apple 공식 JavaScript SDK 연결
  2. state와 nonce를 생성하여 인증 요청
  3. 인증 성공 결과를 백엔드로 전달
  4. Node.js와 TypeScript에서 authorization code와 id_token 검증

Apple Developer 설정은 프레임워크와 무관하게 동일하기 때문에 별도의 글로 분리했습니다.

이 글의 프론트엔드는 Vite와 React를 사용합니다. 백엔드는 특정 프레임워크에 종속되지 않는 Node.js와 TypeScript 코드로 설명합니다.

전체 인증 흐름 이해하기

구현을 시작하기 전에 전체 흐름부터 간단하게 살펴보겠습니다.

Sign in with Apple 전체 흐름
사용자
  ↓ Apple 로그인 버튼 클릭
React + Sign in with Apple JS
  ↓ authorization code, id_token, state
Node.js 백엔드
  ↓ authorization code 교환
Apple Token Endpoint
  ↓ id_token, refresh_token, access_token
Node.js 백엔드
  ↓ 서명, issuer, audience, nonce, 만료 시간 검증
서비스 사용자 조회 또는 생성
  ↓
서비스 세션 발급

React에서 받은 값을 그대로 로그인 정보로 신뢰하면 안 됩니다. 브라우저는 authorization code와 nonce 등을 백엔드로 전달하고, 백엔드가 Apple 서버와 통신하여 인증 결과를 검증해야 합니다.

Apple private key와 client_secret 생성 코드는 반드시 서버에만 둡니다. VITE_ 환경변수나 프론트엔드 코드에 private key를 넣으면 안 됩니다.

준비 사항

실습을 시작하기 전에 다음 항목이 필요합니다.

  • Apple Developer 계정
  • Sign in with Apple을 연결할 도메인
  • Apple Developer에 등록할 HTTPS Return URL
  • React와 TypeScript 프로젝트
  • Apple 인증 결과를 검증할 Node.js 서버

이 글에서는 다음 값을 예시로 사용합니다.

예시 설정값
Primary App ID: com.recodelog.myservice
Services ID: com.recodelog.myservice.web
Domain: recodelog.com
Return URL: https://recodelog.com/auth/apple/callback

예시 값은 자신의 프로젝트에 맞게 변경해야 합니다.

Apple의 웹 Return URL에는 localhost나 IP 주소를 사용할 수 없습니다. 실제 인증 테스트에는 Apple Developer에 등록한 HTTPS 도메인이 필요합니다.

Apple Developer 설정 확인하기

구현을 시작하기 전에 Apple Developer에서 다음 값을 먼저 발급받아야 합니다.

값어디서 발급받는가사용하는 곳
Services IDIdentifiers - Services IDsReact와 서버의 client_id
Return URLServices ID의 Web Authentication 설정React와 서버의 redirect_uri
Team IDApple Developer Membership서버 (client_secret의 iss)
Key IDKeys 메뉴의 Key 상세 화면서버 (client_secret의 kid)
.p8 private keyKey 등록 후 다운로드서버 (client_secret 서명)

Primary App ID 생성부터 Services ID 연결, 도메인과 Return URL 등록, .p8 private key 발급까지는 화면과 함께 아래 글에 정리해 두었습니다.

`Apple 로그인 설정하기 - App ID, Services ID, Key 발급까지`
이미 Services ID와 .p8 private key를 발급받았다면 바로 다음 단계로 진행하면 됩니다.

1. React 프로젝트 환경변수 설정하기

이제 React 프로젝트에 Apple 로그인 설정값을 추가합니다.

Vite 프로젝트의 루트에 .env.local 파일을 생성합니다.

.env.local
VITE_APPLE_CLIENT_ID=com.recodelog.myservice.web
VITE_APPLE_REDIRECT_URI=https://recodelog.com/auth/apple/callback
  • VITE_APPLE_CLIENT_ID: 웹용 Services ID
  • VITE_APPLE_REDIRECT_URI: Apple Developer에 등록한 Return URL

Services ID와 Return URL은 브라우저의 인증 요청에 포함되는 값입니다. 반면 Team ID, Key ID, .p8 private key는 서버에만 둡니다.

프론트엔드에 넣으면 안 되는 값
# 아래 값들은 서버 환경변수입니다.
APPLE_TEAM_ID=TODO
APPLE_KEY_ID=TODO
APPLE_PRIVATE_KEY=TODO

프로젝트 구조는 다음과 같이 구성했습니다.

React Apple 로그인 예제 구조

react-apple-login
src
app.tsx
apple-sign-in.d.ts
app.css
main.tsx
.env.local
package.json
vite.config.ts

2. Apple JavaScript SDK 타입 선언하기

Apple SDK는 외부 스크립트로 로드되므로 TypeScript가 window.AppleID의 타입을 알지 못합니다.

src/apple-sign-in.d.ts 파일을 만들고 필요한 타입을 선언합니다.

src/apple-sign-in.d.ts
interface AppleSignInConfig {
  clientId: string;
  scope: string;
  redirectURI: string;
  state: string;
  nonce: string;
  usePopup: boolean;
}
 
interface AppleSignInAuthorization {
  code: string;
  id_token: string;
  state: string;
}
 
interface AppleSignInUser {
  email?: string;
  name?: {
    firstName?: string;
    lastName?: string;
  };
}
 
interface AppleSignInResponse {
  authorization: AppleSignInAuthorization;
  user?: AppleSignInUser;
}
 
interface AppleSignInSuccessDetail {
  data: AppleSignInResponse;
}
 
interface AppleSignInError {
  error?: string;
}
 
interface Window {
  AppleID?: {
    auth: {
      init: (config: AppleSignInConfig) => void;
    };
  };
}

팝업 방식의 성공 이벤트에서는 인증 응답을 event.detail.data에서 가져옵니다.

3. Apple JavaScript SDK 불러오기

Apple은 웹에서 사용할 수 있는 공식 JavaScript SDK를 제공합니다.

Apple 공식 JavaScript SDK
https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/ko_KR/appleid.auth.js

HTML에 항상 포함하는 대신 로그인 화면에서 필요할 때 스크립트를 로드하도록 작성했습니다.

src/app.tsx
const APPLE_SDK_ID = "apple-sign-in-sdk";
const APPLE_SDK_URL =
  "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/ko_KR/appleid.auth.js";
 
const loadAppleSdk = () => {
  if (window.AppleID) {
    return Promise.resolve();
  }
 
  return new Promise<void>((resolve, reject) => {
    const existingScript = document.getElementById(
      APPLE_SDK_ID,
    ) as HTMLScriptElement | null;
    const script = existingScript ?? document.createElement("script");
 
    script.addEventListener("load", () => resolve(), { once: true });
    script.addEventListener(
      "error",
      () => reject(new Error("Apple 로그인 SDK를 불러오지 못했습니다.")),
      { once: true },
    );
 
    if (!existingScript) {
      script.id = APPLE_SDK_ID;
      script.src = APPLE_SDK_URL;
      script.async = true;
      document.head.appendChild(script);
    }
  });
};

이미 SDK가 로드되어 있다면 새로운 스크립트를 추가하지 않습니다. 이를 통해 React의 재렌더링이나 개발 모드에서도 동일한 SDK가 중복으로 추가되는 것을 방지할 수 있습니다.

4. state와 nonce 생성하기

Apple 로그인 요청에는 state와 nonce를 함께 전달하는 것이 좋습니다.

  • state: 요청과 응답을 연결하고 CSRF 공격을 방지합니다.
  • nonce: 요청과 id_token을 연결하고 재사용 공격을 방지합니다.

두 값은 고정 문자열로 사용하지 않고 로그인 요청마다 새로 생성합니다.

src/app.tsx
const createRandomValue = () => {
  const values = crypto.getRandomValues(new Uint8Array(32));
 
  return Array.from(values, (value) =>
    value.toString(16).padStart(2, "0"),
  ).join("");
};
 
const state = createRandomValue();
const nonce = createRandomValue();

Web Crypto API의 crypto.getRandomValues를 사용하면 인증 요청에 사용할 예측하기 어려운 값을 생성할 수 있습니다.

생성한 값은 인증 응답을 검증할 수 있도록 sessionStorage에 저장합니다.

src/app.tsx
const APPLE_STATE_KEY = "apple-sign-in-state";
const APPLE_NONCE_KEY = "apple-sign-in-nonce";
 
sessionStorage.setItem(APPLE_STATE_KEY, state);
sessionStorage.setItem(APPLE_NONCE_KEY, nonce);

5. Apple 인증 객체 초기화하기

환경변수와 방금 생성한 state, nonce를 사용하여 Apple 인증 객체를 초기화합니다.

src/app.tsx
const appleClientId = import.meta.env.VITE_APPLE_CLIENT_ID;
const appleRedirectUri = import.meta.env.VITE_APPLE_REDIRECT_URI;
 
const prepareAuthorization = () => {
  if (!window.AppleID) {
    return;
  }
 
  const state = createRandomValue();
  const nonce = createRandomValue();
 
  sessionStorage.setItem(APPLE_STATE_KEY, state);
  sessionStorage.setItem(APPLE_NONCE_KEY, nonce);
 
  window.AppleID.auth.init({
    clientId: appleClientId,
    scope: "name email",
    redirectURI: appleRedirectUri,
    state,
    nonce,
    usePopup: true,
  });
};

여기서 중요한 설정은 다음과 같습니다.

  • clientId: 웹용 Services ID
  • scope: 사용자 이름과 이메일 요청
  • redirectURI: 등록한 Return URL
  • usePopup: 페이지 이동 대신 팝업으로 인증 진행

저는 로그인 화면의 상태를 유지하기 위해 usePopup: true를 사용했습니다.

6. Apple 공식 로그인 버튼 추가하기

Apple 공식 SDK는 지정된 HTML 속성을 읽어 Sign in with Apple 버튼을 렌더링합니다.

src/app.tsx
<div
  id="appleid-signin"
  data-color="black"
  data-border="true"
  data-type="sign-in"
  data-border-radius="10"
  data-height="48"
/>

버튼의 크기는 CSS에서 지정할 수 있습니다.

src/app.css
.apple-sign-in-button {
  width: 100%;
  height: 48px;
}

로그인 시도마다 새로운 state와 nonce를 사용하기 위해 클릭 시 인증 설정을 다시 준비합니다.

src/app.tsx
<div className="apple-button-frame" onClickCapture={prepareAuthorization}>
  <div
    id="appleid-signin"
    className="apple-sign-in-button"
    data-color="black"
    data-border="true"
    data-type="sign-in"
    data-border-radius="10"
    data-height="48"
  />
</div>

Apple 공식 버튼을 사용하면 버튼 디자인과 문구를 직접 만들지 않아도 되고 Apple의 웹 버튼 가이드에 맞출 수 있습니다.

7. SDK 로드와 이벤트 리스너 연결하기

컴포넌트가 마운트되면 SDK를 로드하고 인증 객체를 준비합니다.

팝업 인증 성공과 실패 결과는 DOM 이벤트로 전달됩니다.

src/app.tsx
useEffect(() => {
  let isActive = true;
 
  const handleSuccess = (event: Event) => {
    // 다음 단계에서 구현합니다.
  };
 
  const handleFailure = (event: Event) => {
    const error = (event as CustomEvent<AppleSignInError>).detail?.error;
 
    if (error === "user_cancelled_authorize") {
      console.log("사용자가 Apple 로그인을 취소했습니다.");
      return;
    }
 
    console.error("Apple 로그인에 실패했습니다.");
  };
 
  document.addEventListener("AppleIDSignInOnSuccess", handleSuccess);
  document.addEventListener("AppleIDSignInOnFailure", handleFailure);
 
  loadAppleSdk().then(() => {
    if (isActive) {
      prepareAuthorization();
    }
  });
 
  return () => {
    isActive = false;
    document.removeEventListener("AppleIDSignInOnSuccess", handleSuccess);
    document.removeEventListener("AppleIDSignInOnFailure", handleFailure);
  };
}, []);

컴포넌트가 언마운트될 때 이벤트 리스너를 제거하여 동일한 이벤트가 여러 번 처리되는 것을 방지합니다.

8. 인증 성공 결과 검증하고 백엔드로 전달하기

팝업 인증에 성공하면 event.detail.data에서 인증 결과를 가져옵니다.

Apple 인증 성공 응답 예시
{
  "authorization": {
    "code": "일회용 authorization code",
    "id_token": "사용자 identity token",
    "state": "요청에서 보낸 state"
  },
  "user": {
    "email": "사용자 이메일",
    "name": {
      "firstName": "이름",
      "lastName": "성"
    }
  }
}

먼저 응답의 state와 브라우저에 저장한 값을 비교합니다. 일치하지 않으면 해당 응답을 거부합니다.

저장한 nonce는 authorization code와 함께 백엔드로 전달합니다.

src/app.tsx
const handleSuccess = async (event: Event) => {
  const { authorization, user } = (
    event as CustomEvent<AppleSignInSuccessDetail>
  ).detail.data;
 
  const expectedState = sessionStorage.getItem(APPLE_STATE_KEY);
  const expectedNonce = sessionStorage.getItem(APPLE_NONCE_KEY);
 
  if (!expectedState || authorization.state !== expectedState) {
    throw new Error("Apple 로그인 state가 일치하지 않습니다.");
  }
 
  if (!expectedNonce) {
    throw new Error("Apple 로그인 nonce를 찾을 수 없습니다.");
  }
 
  const response = await fetch("/api/auth/apple", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      code: authorization.code,
      nonce: expectedNonce,
      user,
    }),
  });
 
  sessionStorage.removeItem(APPLE_STATE_KEY);
  sessionStorage.removeItem(APPLE_NONCE_KEY);
 
  if (!response.ok) {
    throw new Error("Apple 인증 결과 검증에 실패했습니다.");
  }
 
  const result = await response.json();
  console.log("서비스 로그인 완료", result);
};

브라우저에서 받은 id_token을 단순히 디코딩해서 로그인 처리하면 안 됩니다. 백엔드는 authorization code를 Apple의 Token Endpoint와 교환하고 Apple이 반환한 id_token의 서명과 claim을 검증해야 합니다.

Apple은 user 객체를 최초 동의 시점에만 전달합니다. 이름과 이메일이 필요한 서비스라면 첫 로그인 응답을 서버에서 검증하고 바로 저장해야 합니다.

사용자 이름은 브라우저를 통해 전달되는 값이므로 저장 전에 길이와 허용 문자를 검증하고 화면에 출력할 때도 안전하게 처리합니다.

9. React에서 동작 확인하기

개발 서버를 실행합니다.

terminal
npm run dev

환경변수가 정상적으로 적용되고 Apple SDK가 로드되면 공식 로그인 버튼이 표시됩니다.

버튼을 클릭하면 Apple 로그인 팝업이 열립니다. 실제 인증을 진행하려면 현재 페이지의 도메인과 redirectURI가 Apple Developer에 등록한 설정과 일치해야 합니다.

10. Node.js와 TypeScript에서 인증 결과 검증하기

이제 React가 전달한 authorization code를 백엔드에서 검증합니다.

서버 구현은 다음 세 단계로 나눌 수 있습니다.

  1. .p8 private key로 Apple client_secret 생성
  2. authorization code를 Apple Token Endpoint에서 교환
  3. Apple 공개키로 id_token 검증

JWT 생성과 검증에는 jose를 사용합니다.

terminal
npm install jose

10-1. 서버 환경변수 설정하기

서버 환경변수에 Apple Developer에서 확인한 값을 저장합니다.

서버 .env
APPLE_TEAM_ID=TODO_APPLE_TEAM_ID
APPLE_KEY_ID=TODO_APPLE_KEY_ID
APPLE_CLIENT_ID=com.recodelog.myservice.web
APPLE_REDIRECT_URI=https://recodelog.com/auth/apple/callback
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"

배포 환경에 따라 private key의 줄바꿈이 \n 문자열로 저장될 수 있습니다. 코드에서 실제 줄바꿈으로 변환한 뒤 jose에 전달합니다.

10-2. Apple client_secret 생성하기

Apple Token Endpoint에 authorization code를 전달하려면 developer-signed client_secret JWT가 필요합니다.

src/auth/apple-auth.ts
import { importPKCS8, SignJWT } from "jose";
 
const APPLE_ISSUER = "https://appleid.apple.com";
 
const requireEnv = (name: string) => {
  const value = process.env[name];
 
  if (!value) {
    throw new Error(`${name} 환경변수가 필요합니다.`);
  }
 
  return value;
};
 
const APPLE_TEAM_ID = requireEnv("APPLE_TEAM_ID");
const APPLE_KEY_ID = requireEnv("APPLE_KEY_ID");
const APPLE_CLIENT_ID = requireEnv("APPLE_CLIENT_ID");
const APPLE_PRIVATE_KEY = requireEnv("APPLE_PRIVATE_KEY").replace(/\\n/g, "\n");
 
export const createAppleClientSecret = async () => {
  const privateKey = await importPKCS8(APPLE_PRIVATE_KEY, "ES256");
 
  return new SignJWT({})
    .setProtectedHeader({
      alg: "ES256",
      kid: APPLE_KEY_ID,
    })
    .setIssuer(APPLE_TEAM_ID)
    .setAudience(APPLE_ISSUER)
    .setSubject(APPLE_CLIENT_ID)
    .setIssuedAt()
    .setExpirationTime("5m")
    .sign(privateKey);
};

client_secret은 다음 claim을 사용합니다.

  • iss: Apple Team ID
  • sub: 웹용 Services ID
  • aud: https://appleid.apple.com
  • kid: Sign in with Apple Key ID
  • 서명 알고리즘: ES256

이 JWT는 Apple에 우리 서버를 증명하기 위한 토큰입니다. 사용자의 id_token과 역할이 다릅니다.

10-3. authorization code 교환하기

생성한 client_secret과 React에서 받은 code를 Apple Token Endpoint로 전송합니다.

src/auth/apple-auth.ts
interface AppleTokenResponse {
  access_token: string;
  expires_in: number;
  id_token: string;
  refresh_token?: string;
  token_type: "Bearer";
}
 
interface AppleTokenError {
  error: string;
}
 
const APPLE_REDIRECT_URI = requireEnv("APPLE_REDIRECT_URI");
 
export const exchangeAppleCode = async (code: string) => {
  const clientSecret = await createAppleClientSecret();
  const body = new URLSearchParams({
    client_id: APPLE_CLIENT_ID,
    client_secret: clientSecret,
    code,
    grant_type: "authorization_code",
    redirect_uri: APPLE_REDIRECT_URI,
  });
 
  const response = await fetch("https://appleid.apple.com/auth/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body,
  });
 
  const result = (await response.json()) as
    | AppleTokenResponse
    | AppleTokenError;
 
  if (!response.ok || "error" in result) {
    throw new Error("Apple authorization code 교환에 실패했습니다.");
  }
 
  return result;
};

authorization code는 일회용이며 짧은 시간 동안만 사용할 수 있습니다. 프론트엔드에서 받은 후 가능한 한 바로 백엔드로 전달합니다.

성공하면 Apple은 id_token, access_token, refresh_token 등을 반환합니다.

10-4. id_token 검증하기

Apple이 반환한 id_token은 JWT입니다. 단순히 payload를 디코딩하는 것이 아니라 Apple 공개키로 서명을 검증해야 합니다.

Apple의 공개키 주소는 다음과 같습니다.

https://appleid.apple.com/auth/keys

jose의 createRemoteJWKSet과 jwtVerify를 사용하면 공개키 선택과 JWT claim 검증을 함께 처리할 수 있습니다.

src/auth/apple-auth.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
 
const APPLE_JWKS = createRemoteJWKSet(
  new URL("https://appleid.apple.com/auth/keys"),
);
 
export const verifyAppleIdToken = async (
  idToken: string,
  expectedNonce: string,
) => {
  const { payload } = await jwtVerify(idToken, APPLE_JWKS, {
    algorithms: ["RS256"],
    issuer: "https://appleid.apple.com",
    audience: APPLE_CLIENT_ID,
  });
 
  if (payload.nonce !== expectedNonce) {
    throw new Error("Apple id_token nonce가 일치하지 않습니다.");
  }
 
  if (!payload.sub) {
    throw new Error("Apple 사용자 식별자를 찾을 수 없습니다.");
  }
 
  return {
    appleUserId: payload.sub,
    email: typeof payload.email === "string" ? payload.email : undefined,
    isPrivateEmail:
      payload.is_private_email === true || payload.is_private_email === "true",
  };
};

jwtVerify는 Apple 공개키로 서명을 검증하고, 설정한 issuer, audience, 알고리즘과 JWT 만료 시간도 확인합니다. 별도로 최초 요청에서 사용한 nonce가 token에 포함된 값과 같은지 비교합니다.

검증 시 최소한 다음 값을 확인해야 합니다.

  • JWT 서명
  • alg: RS256
  • iss: https://appleid.apple.com
  • aud: 웹용 Services ID
  • exp: 아직 만료되지 않았는지
  • nonce: 최초 로그인 요청에서 생성한 값과 같은지
  • sub: Apple 사용자의 서비스 내 식별자

10-5. 한 번에 연결하기

앞에서 만든 함수를 하나의 로그인 함수로 연결합니다.

src/auth/apple-auth.ts
interface AppleUserInput {
  email?: string;
  name?: {
    firstName?: string;
    lastName?: string;
  };
}
 
interface AppleLoginInput {
  code: string;
  nonce: string;
  user?: AppleUserInput;
}
 
export const signInWithApple = async ({
  code,
  nonce,
  user,
}: AppleLoginInput) => {
  const tokens = await exchangeAppleCode(code);
  const identity = await verifyAppleIdToken(tokens.id_token, nonce);
 
  return {
    provider: "apple" as const,
    providerAccountId: identity.appleUserId,
    email: user?.email ?? identity.email,
    name: user?.name,
    refreshToken: tokens.refresh_token,
  };
};

서비스에서는 반환된 providerAccountId, 즉 Apple token의 sub를 기준으로 사용자를 조회하거나 생성합니다.

사용자와 세션 생성 예시
const appleAccount = await signInWithApple(requestBody);
 
// TODO: 프로젝트의 DB 구조에 맞게 구현합니다.
const user = await findOrCreateUser({
  provider: appleAccount.provider,
  providerAccountId: appleAccount.providerAccountId,
  email: appleAccount.email,
  name: appleAccount.name,
});
 
// TODO: 프로젝트의 세션 또는 JWT 정책에 맞게 구현합니다.
const session = await createServiceSession(user.id);
Apple 사용자를 이메일 주소로만 식별하지 않습니다. id_token의 sub를 Apple 계정에 대한 서비스 식별자로 저장하는 것이 안전합니다.

refresh_token을 저장한다면 반드시 서버의 암호화된 저장소에서 관리합니다. 회원 탈퇴 또는 Apple 연결 해제 기능을 구현할 때는 Apple의 토큰 폐기 API도 함께 고려해야 합니다.

11. 자주 발생하는 오류

invalid_client

다음 설정이 서로 다를 때 주로 발생합니다.

  • React의 Services ID
  • 서버의 APPLE_CLIENT_ID
  • client_secret의 sub
  • Apple Developer에 등록한 Return URL
  • 실제 요청의 redirect_uri

공백이나 서브도메인 차이도 함께 확인합니다.

invalid_grant

authorization code가 이미 사용되었거나 만료되었을 때 발생할 수 있습니다.

Apple authorization code는 한 번만 사용하고, 받은 직후 서버에서 교환합니다.

로그인 팝업이 열리지 않는 경우

  • Apple SDK가 정상적으로 로드되었는지 확인합니다.
  • 브라우저 콘솔의 오류를 확인합니다.
  • clientId와 redirectURI 환경변수를 확인합니다.
  • Apple Developer에 현재 도메인이 등록되어 있는지 확인합니다.

두 번째 로그인에서 이름이 없는 경우

Apple은 사용자 이름을 최초 동의 시점에만 전달합니다. 두 번째 로그인부터 user 객체가 없을 수 있으므로 첫 로그인에서 이름을 저장해야 합니다.

보안 체크리스트

마지막으로 운영 환경에 적용하기 전에 다음 항목을 확인합니다.

  • .p8 private key가 프론트엔드와 Git에 포함되지 않았는가?
  • state를 요청마다 새로 생성하고 응답과 비교하는가?
  • nonce를 요청마다 새로 생성하고 서버에서 검증하는가?
  • authorization code를 Apple Token Endpoint에서 교환하는가?
  • Apple 공개키로 id_token 서명을 검증하는가?
  • iss, aud, exp, nonce, sub를 확인하는가?
  • Apple 사용자를 이메일이 아니라 sub로 식별하는가?
  • 최초 로그인에서만 오는 이름 정보를 검증하고 저장하는가?
  • refresh token을 서버에서 안전하게 관리하는가?
  • Return URL이 모든 환경에서 Apple Developer 설정과 일치하는가?

마무리

React 웹에서 Apple 로그인을 구현하려면 프론트엔드 버튼뿐만 아니라 Apple Developer 설정과 백엔드 검증까지 함께 구성해야 합니다.

이번 글에서는 다음 내용을 구현했습니다.

  • React에서 Apple 공식 SDK와 버튼 사용
  • state와 nonce를 사용한 인증 요청
  • authorization code를 Node.js 서버로 전달
  • Apple Token Endpoint에서 code 교환
  • Apple 공개키를 사용한 id_token 검증

사용자 DB 연동과 서비스 세션 생성 방식은 프로젝트마다 다르므로 TODO로 남겼습니다. 중요한 점은 브라우저의 인증 결과를 바로 신뢰하지 않고, 서버에서 Apple과 통신하여 검증을 완료한 뒤 우리 서비스의 로그인 세션을 만드는 것입니다.

함께 보면 좋은 글

  • `Apple 로그인 설정하기 - App ID, Services ID, Key 발급까지`
  • `Next.js에서 Apple 로그인 구현하기 - Route Handler 직접 구현과 Auth.js`

참고 문서

  • `Configuring your environment for Sign in with Apple`
  • `Configuring your webpage for Sign in with Apple`
  • `Displaying Sign in with Apple buttons on the web`
  • `Apple Token validation`
  • `Verifying a user`
  • `jose 공식 GitHub 저장소`

이전글

Next.js에서 Apple 로그인 구현하기 - Route Handler 직접 구현과 Auth.js

다음글

Next.js 환경 변수는 언제 사용해야 할까? 사용 기준과 모범 사례


관련 태그

  • react
  • vite
  • apple
  • authentication
  • node.js