Next.js App Router에서 Sign in with Apple을 두 가지 방법으로 구현합니다. Route Handler로 직접 구현하는 방법과 Auth.js(next-auth v5)를 사용하는 방법을 코드와 함께 정리합니다.
이번에는 Next.js App Router에 Apple로 로그인 기능을 구현해 보겠습니다.
앞서 `React 웹에서 Apple 로그인 구현하기`에서는 브라우저에서 Apple 공식 JavaScript SDK를 팝업으로 띄우고, 인증 결과를 별도의 Node.js 서버로 보내 검증했습니다.
Next.js는 상황이 조금 다릅니다. 프론트엔드와 서버가 하나의 프로젝트에 있기 때문에 SDK 없이 서버에서 Apple로 리다이렉트하고 서버가 직접 결과를 받는 방식을 사용할 수 있습니다. 브라우저에는 Services ID조차 노출되지 않고, 인증 관련 코드가 전부 서버에 남습니다.
이 글에서는 두 가지 방법을 각각 처음부터 끝까지 구현합니다.
jose만 사용합니다.먼저 어떤 방법을 선택할지부터 정리하겠습니다.
| 항목 | Route Handler 직접 구현 | Auth.js(next-auth) |
|---|---|---|
| 추가 의존성 | jose 하나 | next-auth |
| 세션 관리 | 직접 구현 | 라이브러리가 제공 |
| 소셜 로그인 추가 | 프로바이더마다 새로 구현 | providers 배열에 추가 |
client_secret 관리 | 요청마다 5분짜리 생성 | 환경변수에 넣고 주기적으로 갱신 |
| 인증 흐름 파악 | 코드에 전부 드러남 | 라이브러리 내부에 감춰짐 |
| 커스터마이징 | 자유롭게 가능 | 콜백이 열어준 범위에서 가능 |
| 버전 안정성 | 표준 OAuth 스펙 | v5는 아직 beta |
판단 기준은 단순합니다.
이미 자체 세션 체계가 있고 Apple 하나만 붙이면 되는 서비스라면 직접 구현이 더 단순합니다. 소셜 로그인을 여러 개 붙일 예정이거나 세션까지 한 번에 해결하고 싶다면 Auth.js가 빠릅니다.
두 방법 모두 Apple Developer 설정은 동일하지만 Return URL 경로가 다릅니다. 이 점만 주의하면 됩니다.
방법 1 (직접 구현): https://recodelog.com/api/auth/apple/callback
방법 2 (Auth.js): https://recodelog.com/api/auth/callback/appleAuth.js는 /api/auth/callback/{provider} 경로를 고정으로 사용합니다. 두 방법을 모두 시험해 볼 생각이라면 Apple Developer의 Return URLs에 두 주소를 함께 등록해 두면 편합니다.
구현을 시작하기 전에 전체 흐름부터 살펴보겠습니다.
사용자
↓ 로그인 버튼 클릭 (/api/auth/apple/start)
Next.js Route Handler
↓ state, nonce 생성 → 쿠키 저장 → Apple로 302 리다이렉트
Apple 로그인 화면
↓ 사용자가 Apple 계정으로 로그인하고 동의
Apple
↓ POST (code, state, user) — 다른 도메인에서 우리 서버로 form 전송
Next.js Route Handler (/api/auth/apple/callback)
↓ 쿠키의 state 비교
↓ authorization code 교환
Apple Token Endpoint
↓ id_token, refresh_token, access_token
Next.js Route Handler
↓ 서명, issuer, audience, nonce, 만료 시간 검증
서비스 사용자 조회 또는 생성
↓ 세션 쿠키 발급 후 리다이렉트
로그인 완료React와 가장 크게 다른 지점은 Apple이 우리 서버로 직접 POST를 보낸다는 점입니다.
Apple은 scope에 name이나 email이 포함되면 response_mode=form_post를 요구합니다. 이 경우 인증이 끝난 뒤 Apple 도메인의 페이지가 우리 Return URL로 form을 제출하는 형태가 됩니다. 즉 다른 사이트에서 우리 도메인으로 들어오는 POST 요청입니다.
SameSite=Lax는 최상위 이동이면서 GET처럼 안전한 메서드일 때만 쿠키를 함께 보냅니다. Apple의 form_post는 POST이기 때문에 Lax 쿠키는 서버에 도착하지 않습니다.
실습을 시작하기 전에 다음 항목이 필요합니다.
.p8 private keyApple Developer에서 발급받아야 하는 값은 다음과 같습니다.
| 값 | 어디서 발급받는가 | 사용하는 곳 |
|---|---|---|
| Services ID | Identifiers - Services IDs | client_id |
| Return URL | Services ID의 Web Authentication 설정 | redirect_uri |
| Team ID | Apple Developer Membership | client_secret의 iss |
| Key ID | Keys 메뉴의 Key 상세 화면 | client_secret의 kid |
.p8 private key | Key 등록 후 다운로드 | client_secret 서명 |
발급 과정은 아래 글에 화면과 함께 정리해 두었습니다.
`Apple 로그인 설정하기 - App ID, Services ID, Key 발급까지`이 글에서는 다음 값을 예시로 사용합니다.
Services ID: com.recodelog.myservice.web
Domain: recodelog.com
Return URL: https://recodelog.com/api/auth/apple/callback먼저 라이브러리 없이 Route Handler만으로 구현해 보겠습니다.
전체 흐름이 코드에 그대로 드러나기 때문에, Auth.js를 사용하더라도 이 부분을 먼저 읽어 두면 내부에서 무슨 일이 일어나는지 이해하기 쉽습니다.
JWT 생성과 검증에는 jose를 사용합니다.
npm install jose프로젝트 루트에 .env.local 파일을 만듭니다.
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/api/auth/apple/callback
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
SESSION_SECRET=TODO_RANDOM_32_BYTES여기서 주목할 점은 NEXT_PUBLIC_ 접두사가 하나도 없다는 것입니다.
React와 Vite로 구현할 때는 Services ID와 Return URL을 브라우저가 알아야 했기 때문에 VITE_ 접두사가 필요했습니다. Next.js에서는 인증 요청 URL을 서버가 만들어서 리다이렉트하므로 브라우저는 이 값들을 몰라도 됩니다.
SESSION_SECRET은 우리 서비스의 세션 JWT를 서명할 키입니다. 다음 명령으로 생성할 수 있습니다.
openssl rand -base64 32이 글에서는 다음과 같이 구성했습니다.
Route Handler는 요청을 받고 응답을 만드는 역할만 담당하고, Apple과 통신하는 로직은 lib/apple 아래에 분리했습니다.
먼저 환경변수를 한곳에서 읽는 파일을 만듭니다.
export const APPLE_ISSUER = "https://appleid.apple.com";
export const APPLE_AUTHORIZE_URL = `${APPLE_ISSUER}/auth/authorize`;
export const APPLE_TOKEN_URL = `${APPLE_ISSUER}/auth/token`;
export const APPLE_JWKS_URL = `${APPLE_ISSUER}/auth/keys`;
const requireEnv = (name: string) => {
const value = process.env[name];
if (!value) {
throw new Error(`${name} 환경변수가 필요합니다.`);
}
return value;
};
export const getAppleConfig = () => ({
teamId: requireEnv("APPLE_TEAM_ID"),
keyId: requireEnv("APPLE_KEY_ID"),
clientId: requireEnv("APPLE_CLIENT_ID"),
redirectUri: requireEnv("APPLE_REDIRECT_URI"),
privateKey: requireEnv("APPLE_PRIVATE_KEY").replace(/\\n/g, "\n"),
});여기서 설정을 상수가 아니라 함수로 감싼 이유가 있습니다.
Next.js는 빌드 과정에서 라우트 모듈을 한 번 평가합니다. 모듈 최상단에서 requireEnv를 호출하면 CI처럼 환경변수가 없는 빌드 환경에서 빌드 자체가 실패합니다. 함수로 감싸 두면 실제 요청이 들어올 때 평가되므로 이런 문제가 생기지 않습니다.
.p8 private key는 배포 환경에 따라 줄바꿈이 \n 문자열로 저장되는 경우가 많아 실제 줄바꿈으로 변환합니다.
state와 nonce는 로그인 요청마다 새로 생성하고 쿠키에 담아 둡니다.
state: 요청과 응답을 연결하고 CSRF 공격을 방지합니다.nonce: 요청과 id_token을 연결하고 재사용 공격을 방지합니다.import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
export const APPLE_STATE_COOKIE = "apple_oauth_state";
export const APPLE_NONCE_COOKIE = "apple_oauth_nonce";
export const APPLE_COOKIE_OPTIONS: Partial<ResponseCookie> = {
httpOnly: true,
secure: true,
sameSite: "none",
path: "/",
maxAge: 60 * 10,
};
export const createRandomValue = () => {
const values = crypto.getRandomValues(new Uint8Array(32));
return Array.from(values, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
};쿠키 옵션이 이 구현에서 가장 중요한 부분입니다.
sameSite: "none": Apple이 보내는 크로스 사이트 POST에도 쿠키를 전송합니다.secure: true: SameSite=None은 Secure 없이는 브라우저가 저장하지 않습니다.httpOnly: true: 스크립트에서 접근할 수 없게 합니다.maxAge: 60 * 10: 로그인 절차에만 필요한 값이므로 10분 후 만료시킵니다.사용자가 로그인 버튼을 누르면 도착할 주소입니다. state와 nonce를 만들어 쿠키에 저장하고 Apple 인증 화면으로 리다이렉트합니다.
import { NextResponse } from "next/server";
import { APPLE_AUTHORIZE_URL, getAppleConfig } from "@/lib/apple/config";
import {
APPLE_COOKIE_OPTIONS,
APPLE_NONCE_COOKIE,
APPLE_STATE_COOKIE,
createRandomValue,
} from "@/lib/apple/oauth";
export const GET = async () => {
const config = getAppleConfig();
const state = createRandomValue();
const nonce = createRandomValue();
const authorizeUrl = new URL(APPLE_AUTHORIZE_URL);
authorizeUrl.searchParams.set("client_id", config.clientId);
authorizeUrl.searchParams.set("redirect_uri", config.redirectUri);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("response_mode", "form_post");
authorizeUrl.searchParams.set("scope", "name email");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("nonce", nonce);
const response = NextResponse.redirect(authorizeUrl);
response.cookies.set(APPLE_STATE_COOKIE, state, APPLE_COOKIE_OPTIONS);
response.cookies.set(APPLE_NONCE_COOKIE, nonce, APPLE_COOKIE_OPTIONS);
return response;
};인증 요청에 사용한 파라미터는 다음과 같습니다.
| 파라미터 | 값 | 설명 |
|---|---|---|
client_id | Services ID | 웹에서는 App ID가 아니라 Services ID입니다 |
redirect_uri | Return URL | Apple Developer 등록값과 완전히 일치해야 합니다 |
response_type | code | authorization code를 받아 서버에서 교환합니다 |
response_mode | form_post | scope에 이름이나 이메일이 있으면 필수입니다 |
scope | name email | 최초 동의 시에만 전달됩니다 |
state | 랜덤 값 | 응답에 그대로 돌아옵니다 |
nonce | 랜덤 값 | id_token에 담겨 돌아옵니다 |
쿠키는 cookies() 대신 response.cookies.set으로 설정했습니다. 리다이렉트 응답과 쿠키를 같은 객체에서 다루기 때문에 순서를 고민할 필요가 없습니다.
Apple Token Endpoint에 authorization code를 보내려면 client_secret이 필요합니다.
Apple의 client_secret은 고정 문자열이 아니라 .p8 private key로 서명한 JWT입니다.
import { importPKCS8, SignJWT } from "jose";
import { APPLE_ISSUER, getAppleConfig } from "@/lib/apple/config";
export const createAppleClientSecret = async () => {
const config = getAppleConfig();
const privateKey = await importPKCS8(config.privateKey, "ES256");
return new SignJWT({})
.setProtectedHeader({
alg: "ES256",
kid: config.keyId,
})
.setIssuer(config.teamId)
.setAudience(APPLE_ISSUER)
.setSubject(config.clientId)
.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과는 역할이 다릅니다.
요청마다 새로 만들고 만료를 5분으로 짧게 잡았습니다. 뒤에서 볼 Auth.js 방식은 미리 만들어 둔 JWT를 환경변수에 넣기 때문에 만료를 직접 관리해야 하는데, 이 방식은 그런 부담이 없습니다.
이제 Apple이 보내 준 code를 토큰으로 교환합니다.
import { APPLE_TOKEN_URL, getAppleConfig } from "@/lib/apple/config";
import { createAppleClientSecret } from "@/lib/apple/client-secret";
interface AppleTokenResponse {
access_token: string;
expires_in: number;
id_token: string;
refresh_token?: string;
token_type: "Bearer";
}
interface AppleTokenError {
error: string;
}
export const exchangeAppleCode = async (code: string) => {
const config = getAppleConfig();
const clientSecret = await createAppleClientSecret();
const body = new URLSearchParams({
client_id: config.clientId,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: config.redirectUri,
});
const response = await fetch(APPLE_TOKEN_URL, {
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는 일회용이고 유효 시간도 짧습니다. 콜백에서 받은 뒤 바로 교환합니다.
redirect_uri는 인증 요청에 사용한 값과 정확히 같아야 합니다. 하나라도 다르면 invalid_grant가 발생합니다.
Apple이 돌려준 id_token은 JWT입니다. payload를 그냥 디코딩해서 사용하면 안 되고 Apple 공개키로 서명을 검증해야 합니다.
import { createRemoteJWKSet, jwtVerify } from "jose";
import { APPLE_ISSUER, APPLE_JWKS_URL, getAppleConfig } from "@/lib/apple/config";
const appleJwks = createRemoteJWKSet(new URL(APPLE_JWKS_URL));
export const verifyAppleIdToken = async (
idToken: string,
expectedNonce: string,
) => {
const config = getAppleConfig();
const { payload } = await jwtVerify(idToken, appleJwks, {
algorithms: ["RS256"],
issuer: APPLE_ISSUER,
audience: config.clientId,
});
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, 알고리즘, 만료 시간까지 함께 확인합니다. nonce만 별도로 비교합니다.
최소한 다음 항목을 확인해야 합니다.
alg: RS256iss: https://appleid.apple.comaud: 웹용 Services IDexp: 아직 만료되지 않았는지nonce: 로그인 요청에서 생성한 값과 같은지sub: Apple 사용자의 서비스 내 식별자createRemoteJWKSet은 공개키를 캐싱하므로 모듈 최상단에 두어 요청마다 다시 받아오지 않게 합니다.
Apple 검증이 끝나면 우리 서비스의 세션을 만들어야 합니다. 여기서는 JWT를 쿠키에 담는 간단한 방식으로 구현합니다.
import { cookies } from "next/headers";
import { jwtVerify, SignJWT } from "jose";
export const SESSION_COOKIE = "session";
const getSessionSecret = () => {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET 환경변수가 필요합니다.");
}
return new TextEncoder().encode(secret);
};
export const createSessionToken = async (userId: string) =>
new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime("7d")
.sign(getSessionSecret());
export const SESSION_COOKIE_OPTIONS = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: 60 * 60 * 24 * 7,
};
export const getSession = async () => {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) {
return null;
}
try {
const { payload } = await jwtVerify(token, getSessionSecret());
return { userId: payload.sub as string };
} catch {
return null;
}
};세션 쿠키는 state, nonce 쿠키와 달리 sameSite: "lax"를 사용합니다. 이 쿠키는 Apple의 크로스 사이트 POST에서 읽을 일이 없고, 로그인 이후 우리 서비스 안에서만 쓰이기 때문입니다.
콜백에서 세션 쿠키를 설정한 뒤 곧바로 리다이렉트하는 구조라, 사용자가 실제로 페이지를 여는 요청은 우리 도메인으로의 GET 이동입니다. 따라서 Lax 쿠키도 문제없이 전달됩니다.
이제 Apple이 POST로 결과를 보내는 지점입니다.
import { NextRequest, NextResponse } from "next/server";
import {
APPLE_COOKIE_OPTIONS,
APPLE_NONCE_COOKIE,
APPLE_STATE_COOKIE,
} from "@/lib/apple/oauth";
import { exchangeAppleCode, verifyAppleIdToken } from "@/lib/apple/token";
import {
SESSION_COOKIE,
SESSION_COOKIE_OPTIONS,
createSessionToken,
} from "@/lib/session";
export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const error = formData.get("error");
if (typeof error === "string") {
return redirectToLogin(request, error);
}
const code = formData.get("code");
const state = formData.get("state");
const expectedState = request.cookies.get(APPLE_STATE_COOKIE)?.value;
const expectedNonce = request.cookies.get(APPLE_NONCE_COOKIE)?.value;
if (typeof code !== "string" || typeof state !== "string") {
return redirectToLogin(request, "invalid_response");
}
if (!expectedState || state !== expectedState) {
return redirectToLogin(request, "invalid_state");
}
if (!expectedNonce) {
return redirectToLogin(request, "missing_nonce");
}
const tokens = await exchangeAppleCode(code);
const identity = await verifyAppleIdToken(tokens.id_token, expectedNonce);
const appleUser = parseAppleUser(formData.get("user"));
// TODO: 프로젝트의 DB 구조에 맞게 구현합니다.
const user = await findOrCreateUser({
provider: "apple",
providerAccountId: identity.appleUserId,
email: appleUser?.email ?? identity.email,
name: appleUser?.name,
});
const response = NextResponse.redirect(new URL("/", request.nextUrl));
response.cookies.set(
SESSION_COOKIE,
await createSessionToken(user.id),
SESSION_COOKIE_OPTIONS,
);
clearAppleCookies(response);
return response;
};Apple이 보낸 값은 쿼리스트링이 아니라 form body에 있으므로 request.formData()로 읽습니다.
state는 우리가 만든 쿠키의 값과 비교합니다. 이 비교가 실패하면 다른 곳에서 만들어진 요청이므로 로그인을 중단합니다.
검증 실패와 쿠키 정리에 사용한 함수는 다음과 같습니다.
const redirectToLogin = (request: NextRequest, reason: string) => {
const loginUrl = new URL("/login", request.nextUrl);
loginUrl.searchParams.set("error", reason);
const response = NextResponse.redirect(loginUrl);
clearAppleCookies(response);
return response;
};
const clearAppleCookies = (response: NextResponse) => {
response.cookies.set(APPLE_STATE_COOKIE, "", {
...APPLE_COOKIE_OPTIONS,
maxAge: 0,
});
response.cookies.set(APPLE_NONCE_COOKIE, "", {
...APPLE_COOKIE_OPTIONS,
maxAge: 0,
});
};쿠키를 지울 때도 설정할 때와 같은 옵션을 사용해야 브라우저가 같은 쿠키로 인식하고 제거합니다.
Apple은 사용자 이름을 최초 동의 시점에만 전달합니다. 그것도 id_token이 아니라 form body의 user 필드에 JSON 문자열로 담아 보냅니다.
{
"name": {
"firstName": "길동",
"lastName": "홍"
},
"email": "user@example.com"
}두 번째 로그인부터는 이 필드가 아예 오지 않습니다. 첫 로그인에서 저장하지 않으면 이름을 다시 받을 방법이 없습니다.
interface AppleUserPayload {
name?: {
firstName?: string;
lastName?: string;
};
email?: string;
}
const parseAppleUser = (value: FormDataEntryValue | null) => {
if (typeof value !== "string") {
return undefined;
}
try {
return JSON.parse(value) as AppleUserPayload;
} catch {
return undefined;
}
};user 필드는 브라우저를 거쳐 전달되는 값이므로 id_token처럼 서명이 검증된 값이 아닙니다. 저장하기 전에 길이와 허용 문자를 검증하고, 화면에 출력할 때도 안전하게 처리해야 합니다.
서버 리다이렉트 방식이라 브라우저에는 링크 하나만 있으면 됩니다.
export const AppleLoginButton = () => {
return (
<a
href='/api/auth/apple/start'
className='flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-black text-white'
>
<svg viewBox='0 0 24 24' className='h-5 w-5' fill='currentColor'>
<path d='M16.365 1.43c0 1.14-.42 2.2-1.12 2.99-.84.95-2.2 1.68-3.34 1.59-.14-1.11.42-2.28 1.09-3.01.77-.85 2.13-1.5 3.37-1.57zM20.5 17.02c-.6 1.38-.89 2-1.66 3.22-1.08 1.7-2.6 3.81-4.48 3.83-1.67.02-2.1-1.09-4.37-1.08-2.27.01-2.74 1.1-4.41 1.08-1.88-.02-3.32-1.93-4.4-3.62C-1.06 16.7-1.38 10.87 1.5 7.9c1.03-1.07 2.5-1.75 4.02-1.75 1.55 0 2.53 1.09 4.34 1.09 1.76 0 2.72-1.09 4.5-1.09 1.35 0 2.78.73 3.8 2-3.34 1.83-2.8 6.6.34 8.87z' />
</svg>
Apple로 계속하기
</a>
);
};버튼을 <a> 태그로 만든 이유가 있습니다.
Next.js의 <Link>는 화면에 보이거나 마우스를 올렸을 때 대상 경로를 미리 요청합니다. 로그인 시작 Route Handler를 <Link>로 연결하면 사용자가 클릭하기 전에 요청이 실행되어 state와 nonce 쿠키가 미리 발급될 수 있습니다. 그래서 prefetch가 없는 일반 <a> 태그를 사용했습니다.
로그인 페이지에서는 이 버튼을 조립하기만 하면 됩니다.
import { AppleLoginButton } from "@/features/auth/apple-login-button";
const Login = () => {
return (
<main className='mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-4 px-6'>
<h1 className='text-2xl font-bold'>로그인</h1>
<AppleLoginButton />
</main>
);
};
export default Login;세션은 서버 컴포넌트에서 바로 읽을 수 있습니다.
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
const Home = async () => {
const session = await getSession();
if (!session) {
redirect("/login");
}
return <main>로그인한 사용자 ID: {session.userId}</main>;
};
export default Home;여기까지가 라이브러리 없이 구현한 Apple 로그인입니다.
이번에는 같은 기능을 Auth.js(next-auth v5)로 구현해 보겠습니다.
방법 1에서 직접 만든 state 생성, 쿠키 관리, code 교환, id_token 검증, 세션 발급을 전부 라이브러리가 처리합니다.
npm install next-auth@beta세션 암호화에 사용할 AUTH_SECRET은 다음 명령으로 생성할 수 있습니다.
npx auth secretAuth.js의 Apple 프로바이더는 clientSecret으로 완성된 JWT 문자열을 받습니다. 방법 1처럼 요청마다 만들어 주지 않기 때문에 미리 생성해서 환경변수에 넣어야 합니다.
가장 간단한 방법은 Auth.js가 제공하는 CLI를 사용하는 것입니다.
npx auth add apple필요한 값을 입력하면 AUTH_APPLE_ID와 AUTH_APPLE_SECRET을 .env 파일에 추가해 줍니다.
직접 생성하고 싶다면 다음 스크립트를 사용합니다.
import { importPKCS8, SignJWT } from "jose";
const APPLE_ISSUER = "https://appleid.apple.com";
const teamId = process.env.APPLE_TEAM_ID!;
const keyId = process.env.APPLE_KEY_ID!;
const clientId = process.env.APPLE_CLIENT_ID!;
const privateKeyPem = process.env.APPLE_PRIVATE_KEY!.replace(/\\n/g, "\n");
// Apple은 6개월(15,777,000초)을 넘는 만료 시간을 거부합니다.
const EXPIRES_IN_SECONDS = 60 * 60 * 24 * 180;
const main = async () => {
const privateKey = await importPKCS8(privateKeyPem, "ES256");
const clientSecret = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: keyId })
.setIssuer(teamId)
.setAudience(APPLE_ISSUER)
.setSubject(clientId)
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + EXPIRES_IN_SECONDS)
.sign(privateKey);
console.log(clientSecret);
};
main();AUTH_SECRET=TODO_GENERATED_SECRET
AUTH_APPLE_ID=com.recodelog.myservice.web
AUTH_APPLE_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6...
AUTH_URL=https://recodelog.com
AUTH_TRUST_HOST=trueAuth.js는 AUTH_APPLE_ID와 AUTH_APPLE_SECRET이라는 이름을 자동으로 인식합니다. 이 이름을 그대로 사용하면 프로바이더 설정에 값을 직접 넣지 않아도 됩니다.
Apple Developer의 Return URLs에는 Auth.js가 사용하는 경로를 등록합니다.
https://recodelog.com/api/auth/callback/apple프로젝트 루트에 auth.ts 파일을 만듭니다.
import NextAuth from "next-auth";
import Apple from "next-auth/providers/apple";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Apple],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
callbacks: {
jwt: async ({ token, account, profile }) => {
if (account?.provider === "apple" && profile) {
token.appleUserId = profile.sub;
// Apple은 최초 동의 시에만 user 필드를 전달합니다.
if (profile.user) {
const { firstName, lastName } = profile.user.name;
token.name = `${lastName}${firstName}`;
}
}
return token;
},
session: async ({ session, token }) => {
session.user.id = token.appleUserId as string;
return session;
},
},
});providers: [Apple]처럼 괄호 없이 넘기면 Auth.js가 AUTH_APPLE_ID와 AUTH_APPLE_SECRET 환경변수를 읽어 옵니다. 값을 직접 넣고 싶다면 다음처럼 작성합니다.
Apple({
clientId: process.env.AUTH_APPLE_ID,
clientSecret: process.env.AUTH_APPLE_SECRET,
});Auth.js가 만들어 준 핸들러를 그대로 내보냅니다.
import { handlers } from "@/auth";
export const { GET, POST } = handlers;GET과 POST를 모두 내보내야 합니다. Apple의 form_post 콜백이 POST로 도착하기 때문입니다.
Auth.js의 signIn은 서버 액션에서 호출합니다.
import { signIn } from "@/auth";
export const AppleSignInForm = () => {
return (
<form
action={async () => {
"use server";
await signIn("apple", { redirectTo: "/" });
}}
>
<button
type='submit'
className='flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-black text-white'
>
Apple로 계속하기
</button>
</form>
);
};redirectTo에 로그인 완료 후 이동할 경로를 지정합니다.
서버 컴포넌트에서는 auth()를 호출합니다.
import { redirect } from "next/navigation";
import { auth } from "@/auth";
const Home = async () => {
const session = await auth();
if (!session) {
redirect("/login");
}
return <main>안녕하세요, {session.user?.name}님</main>;
};
export default Home;클라이언트 컴포넌트에서 세션이 필요하다면 SessionProvider와 useSession을 사용합니다.
방법 1에서 직접 신경 썼던 부분을 Auth.js가 어떻게 처리하는지 확인해 두면 문제가 생겼을 때 원인을 찾기 쉽습니다.
가장 중요한 쿠키 문제부터 보겠습니다. Auth.js의 기본 쿠키 설정은 sameSite: "lax"입니다. 하지만 프로바이더가 response_mode=form_post를 사용하면 state와 nonce 쿠키에 한해 sameSite를 none으로, secure를 true로 바꿉니다.
if (
provider.authorization?.url.searchParams.get("response_mode") === "form_post"
) {
options.cookies.state.options.sameSite = "none";
options.cookies.state.options.secure = true;
options.cookies.nonce.options.sameSite = "none";
options.cookies.nonce.options.secure = true;
}방법 1에서 직접 작성했던 것과 정확히 같은 처리입니다. 그래서 Auth.js를 쓰면 이 부분은 신경 쓰지 않아도 됩니다. 다만 Secure 쿠키를 사용하므로 HTTPS가 아닌 환경에서는 동작하지 않습니다.
Apple 프로바이더의 기본 설정도 정리하면 다음과 같습니다.
| 설정 | 값 | 의미 |
|---|---|---|
type | oidc | id_token을 검증하는 OIDC 흐름 |
scope | name email | 최초 동의 시 이름과 이메일 요청 |
response_mode | form_post | 콜백을 POST로 받음 |
checks | nonce, state | 두 값을 모두 검증 |
token_endpoint_auth_method | client_secret_post | body에 client_secret 전달 |
사용자 이름은 Apple 프로바이더가 form body의 user 필드를 파싱해 profile.user에 넣어 줍니다. 앞의 jwt 콜백에서 사용한 값이 바로 이것입니다. 이 값 역시 최초 동의 시에만 오기 때문에, DB 어댑터를 사용한다면 첫 로그인에서 반드시 저장해야 합니다.
Auth.js 방식의 가장 불편한 점은 6개월마다 client_secret을 갱신해야 한다는 것입니다.
Auth.js v5는 설정을 함수로 넘기는 방식을 지원합니다. 이를 이용하면 방법 1처럼 요청마다 짧은 수명의 client_secret을 만들 수 있습니다.
import NextAuth from "next-auth";
import Apple from "next-auth/providers/apple";
import { createAppleClientSecret } from "@/lib/apple/client-secret";
export const { handlers, signIn, signOut, auth } = NextAuth(async () => ({
providers: [
Apple({
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: await createAppleClientSecret(),
}),
],
session: { strategy: "jwt" },
}));createAppleClientSecret은 방법 1에서 만든 함수를 그대로 사용합니다. 환경변수도 AUTH_APPLE_SECRET 대신 APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY를 사용하게 됩니다.
갱신 일정을 관리할 필요가 없어지는 대신 요청마다 서명 연산이 한 번씩 추가됩니다. 로그인 요청은 빈번하지 않으므로 대부분의 서비스에서는 이 방식이 더 편합니다.
가장 많이 만나는 오류입니다. 다음 값이 서로 다를 때 발생합니다.
client_id와 실제 Services IDclient_secret의 sub와 Services IDclient_secret의 iss와 Team IDclient_secret 서명에 사용한 Key와 kid의 Key IDAUTH_APPLE_SECRET의 만료 여부App ID를 client_id로 잘못 넣는 경우가 특히 많습니다. 웹에서는 Services ID를 사용합니다.
authorization code가 이미 사용되었거나 만료되었을 때, 또는 redirect_uri가 인증 요청과 다를 때 발생합니다.
code는 일회용이므로 콜백에서 받은 직후 한 번만 교환합니다. 개발 중 콜백 로직을 두 번 실행하는 코드가 있는지도 확인해 봅니다.
Next.js에서 Apple 로그인을 붙일 때 가장 자주 겪는 문제입니다.
Apple의 콜백은 다른 도메인에서 우리 서버로 보내는 POST이기 때문에 SameSite=Lax 쿠키가 전송되지 않습니다. 다음을 확인합니다.
state, nonce 쿠키가 SameSite=None; Secure로 설정되어 있는지Secure 쿠키는 HTTP에서 저장되지 않습니다)path가 /로 설정되어 있는지Apple은 웹 Return URL로 localhost와 http를 허용하지 않습니다. Secure 쿠키도 함께 필요하므로 로컬 HTTP 환경에서는 어떤 방법으로도 로그인이 완료되지 않습니다.
Auth.js의 redirectProxyUrl은 Apple에서 사용할 수 없습니다. response_mode=form_post를 사용하는 프로바이더는 리다이렉트 프록시를 지원하지 않기 때문에, Vercel의 Preview 배포처럼 URL이 매번 바뀌는 환경에도 적용하기 어렵습니다.
Apple은 사용자 이름을 최초 동의 시점에만 전달합니다. 첫 로그인에서 저장하지 않으면 다시 받을 방법이 없습니다.
테스트 중이라면 Apple ID 설정의 Apple로 로그인 목록에서 해당 앱의 사용을 중단한 뒤 다시 로그인하면 최초 동의 상태로 되돌릴 수 있습니다.
Apple 관련 로직을 미들웨어에 넣으면 Edge 런타임에서 실행됩니다. node:crypto의 createPrivateKey를 사용했다면 여기서 실패합니다.
jose의 importPKCS8은 Web Crypto API를 사용하므로 양쪽 런타임에서 모두 동작합니다. Route Handler의 기본 런타임은 Node.js이므로, 프로젝트 전체를 Edge로 설정한 경우가 아니라면 대부분 문제가 없습니다.
운영 환경에 적용하기 전에 다음 항목을 확인합니다.
.p8 private key가 클라이언트 번들과 Git에 포함되지 않았는가?NEXT_PUBLIC_이 붙어 있지 않은가?state를 요청마다 새로 생성하고 콜백에서 비교하는가?nonce를 요청마다 새로 생성하고 id_token과 비교하는가?state, nonce 쿠키가 httpOnly, Secure, SameSite=None인가?id_token 서명을 검증하는가?iss, aud, exp, nonce, sub를 모두 확인하는가?sub로 식별하는가?user 필드를 검증하고 저장하는가?httpOnly, Secure인가?client_secret 만료 일정을 관리하고 있는가?Next.js에서 Apple 로그인을 구현하는 두 가지 방법을 각각 정리했습니다.
Route Handler 직접 구현에서는 다음 내용을 다뤘습니다.
state, nonce를 SameSite=None 쿠키로 보관request.formData()로 처리.p8 private key로 요청마다 client_secret 생성id_token 검증Auth.js 구현에서는 다음 내용을 다뤘습니다.
client_secret JWT 사전 생성과 환경변수 설정providers: [Apple] 한 줄로 프로바이더 연결jwt 콜백에서 최초 동의 시점의 이름 저장client_secret 만료를 피하는 방법React와 Vite 구현과 비교하면 가장 큰 차이는 브라우저가 Apple 관련 설정값을 전혀 몰라도 된다는 점입니다. 인증 요청 생성부터 검증까지 전부 서버에서 처리할 수 있기 때문에, Next.js에서는 SDK를 사용한 팝업 방식보다 서버 리다이렉트 방식이 더 자연스럽습니다.
사용자 DB 연동과 세션 정책은 프로젝트마다 다르므로 TODO로 남겼습니다. 중요한 점은 브라우저를 거쳐 온 값을 그대로 신뢰하지 않고, 서버가 Apple과 직접 통신해 검증을 마친 뒤 우리 서비스의 세션을 만드는 것입니다.
Apple 로그인 설정하기 - App ID, Services ID, Key 발급까지
React 웹에서 Apple 로그인 구현하기 - Sign in with Apple JS와 Node.js 검증