React와 Vite에서 Sign in with Apple JS를 연결하고, Node.js와 TypeScript에서 authorization code와 id_token을 검증하는 과정을 단계별로 정리합니다.
이번에는 React 웹 애플리케이션에 Apple로 로그인 기능을 구현해 보겠습니다.
Apple 로그인은 React에 버튼 하나만 추가한다고 끝나지 않습니다. Apple Developer에서 App ID와 Services ID를 연결하고, 웹 도메인과 Return URL을 등록한 뒤, 백엔드에서 Apple이 전달한 인증 결과를 검증해야 합니다.
이 글에서는 다음 과정을 순서대로 진행합니다.
state와 nonce를 생성하여 인증 요청id_token 검증Apple Developer 설정은 프레임워크와 무관하게 동일하기 때문에 별도의 글로 분리했습니다.
구현을 시작하기 전에 전체 흐름부터 간단하게 살펴보겠습니다.
사용자
↓ 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 서버와 통신하여 인증 결과를 검증해야 합니다.
실습을 시작하기 전에 다음 항목이 필요합니다.
이 글에서는 다음 값을 예시로 사용합니다.
Primary App ID: com.recodelog.myservice
Services ID: com.recodelog.myservice.web
Domain: recodelog.com
Return URL: https://recodelog.com/auth/apple/callback예시 값은 자신의 프로젝트에 맞게 변경해야 합니다.
구현을 시작하기 전에 Apple Developer에서 다음 값을 먼저 발급받아야 합니다.
| 값 | 어디서 발급받는가 | 사용하는 곳 |
|---|---|---|
| Services ID | Identifiers - Services IDs | React와 서버의 client_id |
| Return URL | Services ID의 Web Authentication 설정 | React와 서버의 redirect_uri |
| Team ID | Apple Developer Membership | 서버 (client_secret의 iss) |
| Key ID | Keys 메뉴의 Key 상세 화면 | 서버 (client_secret의 kid) |
.p8 private key | Key 등록 후 다운로드 | 서버 (client_secret 서명) |
Primary App ID 생성부터 Services ID 연결, 도메인과 Return URL 등록, .p8 private key 발급까지는 화면과 함께 아래 글에 정리해 두었습니다.
이제 React 프로젝트에 Apple 로그인 설정값을 추가합니다.
Vite 프로젝트의 루트에 .env.local 파일을 생성합니다.
VITE_APPLE_CLIENT_ID=com.recodelog.myservice.web
VITE_APPLE_REDIRECT_URI=https://recodelog.com/auth/apple/callbackVITE_APPLE_CLIENT_ID: 웹용 Services IDVITE_APPLE_REDIRECT_URI: Apple Developer에 등록한 Return URLServices ID와 Return URL은 브라우저의 인증 요청에 포함되는 값입니다. 반면 Team ID, Key ID, .p8 private key는 서버에만 둡니다.
# 아래 값들은 서버 환경변수입니다.
APPLE_TEAM_ID=TODO
APPLE_KEY_ID=TODO
APPLE_PRIVATE_KEY=TODO프로젝트 구조는 다음과 같이 구성했습니다.
Apple SDK는 외부 스크립트로 로드되므로 TypeScript가 window.AppleID의 타입을 알지 못합니다.
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에서 가져옵니다.
Apple은 웹에서 사용할 수 있는 공식 JavaScript SDK를 제공합니다.
https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/ko_KR/appleid.auth.jsHTML에 항상 포함하는 대신 로그인 화면에서 필요할 때 스크립트를 로드하도록 작성했습니다.
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가 중복으로 추가되는 것을 방지할 수 있습니다.
Apple 로그인 요청에는 state와 nonce를 함께 전달하는 것이 좋습니다.
state: 요청과 응답을 연결하고 CSRF 공격을 방지합니다.nonce: 요청과 id_token을 연결하고 재사용 공격을 방지합니다.두 값은 고정 문자열로 사용하지 않고 로그인 요청마다 새로 생성합니다.
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에 저장합니다.
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);환경변수와 방금 생성한 state, nonce를 사용하여 Apple 인증 객체를 초기화합니다.
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 IDscope: 사용자 이름과 이메일 요청redirectURI: 등록한 Return URLusePopup: 페이지 이동 대신 팝업으로 인증 진행저는 로그인 화면의 상태를 유지하기 위해 usePopup: true를 사용했습니다.
Apple 공식 SDK는 지정된 HTML 속성을 읽어 Sign in with Apple 버튼을 렌더링합니다.
<div
id="appleid-signin"
data-color="black"
data-border="true"
data-type="sign-in"
data-border-radius="10"
data-height="48"
/>버튼의 크기는 CSS에서 지정할 수 있습니다.
.apple-sign-in-button {
width: 100%;
height: 48px;
}로그인 시도마다 새로운 state와 nonce를 사용하기 위해 클릭 시 인증 설정을 다시 준비합니다.
<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의 웹 버튼 가이드에 맞출 수 있습니다.
컴포넌트가 마운트되면 SDK를 로드하고 인증 객체를 준비합니다.
팝업 인증 성공과 실패 결과는 DOM 이벤트로 전달됩니다.
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);
};
}, []);컴포넌트가 언마운트될 때 이벤트 리스너를 제거하여 동일한 이벤트가 여러 번 처리되는 것을 방지합니다.
팝업 인증에 성공하면 event.detail.data에서 인증 결과를 가져옵니다.
{
"authorization": {
"code": "일회용 authorization code",
"id_token": "사용자 identity token",
"state": "요청에서 보낸 state"
},
"user": {
"email": "사용자 이메일",
"name": {
"firstName": "이름",
"lastName": "성"
}
}
}먼저 응답의 state와 브라우저에 저장한 값을 비교합니다. 일치하지 않으면 해당 응답을 거부합니다.
저장한 nonce는 authorization code와 함께 백엔드로 전달합니다.
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을 검증해야 합니다.
사용자 이름은 브라우저를 통해 전달되는 값이므로 저장 전에 길이와 허용 문자를 검증하고 화면에 출력할 때도 안전하게 처리합니다.
개발 서버를 실행합니다.
npm run dev환경변수가 정상적으로 적용되고 Apple SDK가 로드되면 공식 로그인 버튼이 표시됩니다.
버튼을 클릭하면 Apple 로그인 팝업이 열립니다. 실제 인증을 진행하려면 현재 페이지의 도메인과 redirectURI가 Apple Developer에 등록한 설정과 일치해야 합니다.
이제 React가 전달한 authorization code를 백엔드에서 검증합니다.
서버 구현은 다음 세 단계로 나눌 수 있습니다.
.p8 private key로 Apple client_secret 생성id_token 검증JWT 생성과 검증에는 jose를 사용합니다.
npm install jose서버 환경변수에 Apple Developer에서 확인한 값을 저장합니다.
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에 전달합니다.
Apple Token Endpoint에 authorization code를 전달하려면 developer-signed client_secret JWT가 필요합니다.
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 IDsub: 웹용 Services IDaud: https://appleid.apple.comkid: Sign in with Apple Key ID이 JWT는 Apple에 우리 서버를 증명하기 위한 토큰입니다. 사용자의 id_token과 역할이 다릅니다.
생성한 client_secret과 React에서 받은 code를 Apple Token Endpoint로 전송합니다.
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 등을 반환합니다.
Apple이 반환한 id_token은 JWT입니다. 단순히 payload를 디코딩하는 것이 아니라 Apple 공개키로 서명을 검증해야 합니다.
Apple의 공개키 주소는 다음과 같습니다.
https://appleid.apple.com/auth/keysjose의 createRemoteJWKSet과 jwtVerify를 사용하면 공개키 선택과 JWT claim 검증을 함께 처리할 수 있습니다.
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에 포함된 값과 같은지 비교합니다.
검증 시 최소한 다음 값을 확인해야 합니다.
alg: RS256iss: https://appleid.apple.comaud: 웹용 Services IDexp: 아직 만료되지 않았는지nonce: 최초 로그인 요청에서 생성한 값과 같은지sub: Apple 사용자의 서비스 내 식별자앞에서 만든 함수를 하나의 로그인 함수로 연결합니다.
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);refresh_token을 저장한다면 반드시 서버의 암호화된 저장소에서 관리합니다. 회원 탈퇴 또는 Apple 연결 해제 기능을 구현할 때는 Apple의 토큰 폐기 API도 함께 고려해야 합니다.
다음 설정이 서로 다를 때 주로 발생합니다.
APPLE_CLIENT_IDsubredirect_uri공백이나 서브도메인 차이도 함께 확인합니다.
authorization code가 이미 사용되었거나 만료되었을 때 발생할 수 있습니다.
Apple authorization code는 한 번만 사용하고, 받은 직후 서버에서 교환합니다.
clientId와 redirectURI 환경변수를 확인합니다.Apple은 사용자 이름을 최초 동의 시점에만 전달합니다. 두 번째 로그인부터 user 객체가 없을 수 있으므로 첫 로그인에서 이름을 저장해야 합니다.
마지막으로 운영 환경에 적용하기 전에 다음 항목을 확인합니다.
.p8 private key가 프론트엔드와 Git에 포함되지 않았는가?state를 요청마다 새로 생성하고 응답과 비교하는가?nonce를 요청마다 새로 생성하고 서버에서 검증하는가?id_token 서명을 검증하는가?iss, aud, exp, nonce, sub를 확인하는가?sub로 식별하는가?React 웹에서 Apple 로그인을 구현하려면 프론트엔드 버튼뿐만 아니라 Apple Developer 설정과 백엔드 검증까지 함께 구성해야 합니다.
이번 글에서는 다음 내용을 구현했습니다.
state와 nonce를 사용한 인증 요청id_token 검증사용자 DB 연동과 서비스 세션 생성 방식은 프로젝트마다 다르므로 TODO로 남겼습니다. 중요한 점은 브라우저의 인증 결과를 바로 신뢰하지 않고, 서버에서 Apple과 통신하여 검증을 완료한 뒤 우리 서비스의 로그인 세션을 만드는 것입니다.
Next.js에서 Apple 로그인 구현하기 - Route Handler 직접 구현과 Auth.js
Next.js 환경 변수는 언제 사용해야 할까? 사용 기준과 모범 사례