-
[iOS][Spring] Apple login 붙이기기타개발지식/풀스택개발 2026. 6. 19. 15:23
개요
Apple login은 다른 소셜 로그인보다 조금 특이하다.
iOS 앱에서는 Apple이 제공하는
AuthenticationServices프레임워크를 쓰고, 백엔드에서는 identity token을 검증하거나 authorization code를 Apple 서버에 교환해서 확인한다.앱만 믿고
user값을 서버에 보내는 식으로 만들면 안된다.Spring 백엔드가 Apple이 서명한 token인지 확인한 뒤, 그 안의
sub값을 provider user id로 써야 한다.전체 흐름
iOS 앱 -> Sign in with Apple 버튼 표시 -> Apple ID 인증 -> identityToken / authorizationCode 획득 -> POST /api/auth/apple-login Spring 백엔드 -> Apple public key로 identity token 서명 검증 -> issuer, audience, expiration 확인 -> provider = APPLE, providerUserId = sub 로 회원 조회 -> 없으면 회원 생성 -> 우리 서비스 JWT access token / refresh token 발급 iOS 앱 -> 우리 서비스 토큰 저장 -> 이후 API 요청은 우리 서비스 access token으로 인증Apple login에서 중요한 건
identityToken과 우리 서비스 토큰을 섞지 않는 것이다.identity token은 Apple이 이 사용자를 인증했다는 증거이고, 앱 내부 세션은 우리 백엔드가 새로 발급해야 한다.
Apple Developer 설정
Apple Developer에서 App ID에 Sign in with Apple capability를 켠다.
Apple Developer > Certificates, Identifiers & Profiles > Identifiers > 내 App ID 선택 > Sign in with Apple 체크 > SaveApple 공식 도움말 기준으로 App ID의 capability는 나중에 수정할 수 있다.
다만 capability를 바꾼 뒤에는 provisioning profile이 꼬일 수 있으니, Xcode에서 signing을 한 번 갱신하거나 profile을 다시 만들어야 할 때가 있다.
Xcode에서도 capability를 추가한다.
Xcode > Target > Signing & Capabilities > + Capability > Sign in with Apple이렇게 하면 entitlement에
com.apple.developer.applesignin이 들어간다.Apple Developer 사이트에서 실제로 누르는 순서
Apple Developer Portal 쪽은 로그인 뒤 화면이라 공개 문서에 캡쳐가 많이 없다.
그래도 실제 메뉴 흐름은 아래처럼 보면 된다.
Apple Developer > Certificates, Identifiers & Profiles > Identifiers > App IDs > 내 Bundle ID 선택 > Sign in with Apple 체크 > Save새 App ID를 만들 때도 같은 화면에서 Sign in with Apple capability를 체크한다.
이미 App ID가 있으면 새로 만들 필요 없이 기존 Bundle ID를 열어서 capability만 추가하면 된다.
먼저 Identifiers 화면에서 내 앱의 Bundle ID를 찾거나, 새 앱이면 + 버튼으로 App ID를 만든다.

Apple login을 iOS native 앱에서만 쓸 거면 App ID capability만으로 시작할 수 있다.
웹 로그인, Android, Firebase, 웹 redirect 기반 흐름까지 같이 쓸 거면 Service ID도 필요하다.
Apple Developer > Certificates, Identifiers & Profiles > Identifiers > Services IDs > 새 Service ID 생성 > Sign in with Apple 체크 > ConfigureService ID의 Configure에서는 Primary App ID를 고르고, 웹 도메인과 Return URL을 넣는다.
iOS 앱에서 받은 identity token의 audience는 Bundle ID인 경우가 많고, Service ID는 웹 OAuth 흐름에서 주로 쓴다.
이 둘을 섞으면 백엔드 aud 검증에서 계속 실패한다.

백엔드에서 authorization code exchange, revoke, client secret 발급까지 해야 한다면 Key도 만든다.
Apple Developer > Certificates, Identifiers & Profiles > Keys > + 버튼 > Sign in with Apple 체크 > Configure > Primary App ID 선택 > Register > .p8 다운로드
Key를 만들고 나면 Key ID와 Team ID, 그리고 다운로드한
.p8파일이 필요하다..p8은 한 번만 다운로드할 수 있으니 안전한 곳에 보관하고, 절대 깃에 올리면 안 된다.
정리하면 iOS native 앱만 먼저 붙일 때는 이 정도로 시작한다.
필수에 가까움 - App ID에 Sign in with Apple capability 켜기 - Xcode Signing & Capabilities에 Sign in with Apple 추가 - 백엔드는 identityToken 검증 필요해지면 추가 - Service ID: 웹 redirect 로그인까지 쓸 때 - Key(.p8): authorization code exchange, revoke, client secret 발급이 필요할 때백엔드 검증에 필요한 값
native iOS 앱에서 받은 identity token의 audience는 보통 Bundle ID다.
apple: issuer: https://appleid.apple.com jwk-url: https://appleid.apple.com/auth/keys client-id: com.example.myapp여기서
client-id라고 적었지만, native iOS 기준으로는 Bundle ID라고 생각하면 된다.웹 로그인이나 Android에서 Apple login을 붙이는 경우에는 Service ID가 audience가 될 수 있는데, iOS 앱만 대상으로 한다면 Bundle ID 쪽이 보통 맞다.
authorization code를 Apple의
/auth/tokenendpoint로 교환하려면 별도의 private key가 필요하다.Apple Developer > Certificates, Identifiers & Profiles > Keys > Sign in with Apple key 생성 > Key ID, Team ID, .p8 파일 보관.p8파일은 절대 깃에 올리면 안된다.처음 구현은 identity token 검증부터 붙이고, token exchange나 revoke가 필요해졌을 때 private key 흐름을 추가하는 편이 덜 복잡하다.
iOS 로그인 코드
iOS에서는
ASAuthorizationAppleIDProvider로 요청을 만든다.import AuthenticationServices import CryptoKit final class AppleSignInService: NSObject { private var continuation: CheckedContinuation<AppleLoginPayload, Error>? private var currentNonce: String? func login() async throws -> AppleLoginPayload { try await withCheckedThrowingContinuation { continuation in self.continuation = continuation let nonce = Self.randomNonceString() currentNonce = nonce let provider = ASAuthorizationAppleIDProvider() let request = provider.createRequest() request.requestedScopes = [.fullName, .email] request.nonce = Self.sha256(nonce) let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self controller.presentationContextProvider = self controller.performRequests() } } }nonce는 replay 공격을 줄이기 위해 쓰는 값이다.
백엔드에서도 nonce까지 검증하면 더 단단하지만, 처음 구현에서는 identity token 서명, issuer, audience, expiration부터 정확히 잡는 게 우선이다.
iOS 결과 처리
extension AppleSignInService: ASAuthorizationControllerDelegate { func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { continuation?.resume(throwing: AuthError.loginFailed) return } guard let identityTokenData = credential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) else { continuation?.resume(throwing: AuthError.missingProviderToken) return } let authorizationCode = credential.authorizationCode .flatMap { String(data: $0, encoding: .utf8) } continuation?.resume( returning: AppleLoginPayload( idToken: identityToken, authorizationCode: authorizationCode, nonce: currentNonce, fullName: credential.fullName, email: credential.email ) ) } func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error ) { continuation?.resume(throwing: error) } }Apple은 이름과 이메일을 항상 주지 않는다.
처음 가입할 때 한 번 내려오고, 그 다음 로그인부터는 안 내려오는 경우가 흔하다.
그래서 백엔드에서 email이나 name이 null이어도 로그인은 되게 만들어야 한다.
iOS에서 백엔드로 보내기
{ "idToken": "apple-identity-token", "authorizationCode": "apple-authorization-code", "nonce": "raw-nonce", "deviceId": "device-uuid", "timezone": "Asia/Seoul", "locale": "ko-KR" }백엔드가 authorization code exchange를 아직 안 한다면
authorizationCode는 받아만 두고 사용하지 않아도 된다.그래도 나중에 revoke나 token exchange를 붙일 수 있어서 요청 필드에 같이 열어두는 편이 좋다.
Spring 요청 DTO
public record AppleLoginRequest( String idToken, String authorizationCode, String nonce, String deviceId, String timezone, String locale ) { }Spring에서 identity token 검증
Apple identity token은 JWT다.
Apple의 public key endpoint에서 key를 받아 서명을 검증하고, claim을 확인한다.
GET https://appleid.apple.com/auth/keysJava에서는 Nimbus JOSE JWT를 많이 쓴다.
implementation "com.nimbusds:nimbus-jose-jwt"검증 코드는 대략 이런 흐름이다.
@Component public class AppleTokenVerifier { private static final String ISSUER = "https://appleid.apple.com"; private final String audience = "com.example.myapp"; public AppleUser verify(String idToken) { try { SignedJWT jwt = SignedJWT.parse(idToken); String keyId = jwt.getHeader().getKeyID(); JWKSet jwkSet = JWKSet.load(new URL("https://appleid.apple.com/auth/keys")); RSAKey rsaKey = (RSAKey) jwkSet.getKeyByKeyId(keyId); if (rsaKey == null) { throw new AuthException("Apple public key not found"); } boolean verified = jwt.verify(new RSASSAVerifier(rsaKey.toRSAPublicKey())); if (!verified) { throw new AuthException("Invalid Apple token signature"); } JWTClaimsSet claims = jwt.getJWTClaimsSet(); if (!ISSUER.equals(claims.getIssuer())) { throw new AuthException("Invalid Apple issuer"); } if (!claims.getAudience().contains(audience)) { throw new AuthException("Invalid Apple audience"); } if (claims.getExpirationTime().before(new Date())) { throw new AuthException("Expired Apple token"); } return new AppleUser( claims.getSubject(), claims.getStringClaim("email") ); } catch (Exception e) { throw new AuthException("Apple login failed", e); } } }실제 운영에서는 Apple JWK를 매번 새로 받기보다 캐시하는 게 좋다.
다만 key rotation이 있을 수 있으니, kid가 안 맞으면 한 번 다시 가져오는 식으로 처리하면 된다.
회원 처리
Apple의
subclaim을 provider user id로 저장한다.@Service public class AuthService { private final AppleTokenVerifier appleTokenVerifier; private final MemberRepository memberRepository; private final TokenService tokenService; public LoginResponse loginWithApple(AppleLoginRequest request) { AppleUser appleUser = appleTokenVerifier.verify(request.idToken()); Member member = memberRepository .findByProviderAndProviderUserId(Provider.APPLE, appleUser.id()) .orElseGet(() -> memberRepository.save( Member.social(Provider.APPLE, appleUser.id(), appleUser.email()) )); TokenPair tokenPair = tokenService.issue(member.getId(), request.deviceId()); return LoginResponse.from(member, tokenPair); } }여기도 마찬가지로 email을 유니크 키로 삼지 않는 게 좋다.
Apple은 비공개 릴레이 이메일을 줄 수도 있고, 처음 이후 email을 안 줄 수도 있다.
authorization code 교환은 언제 필요한가
identity token 검증만으로도 로그인 자체는 처리할 수 있다.
하지만 Apple 서버에서 authorization code를 token으로 교환하거나, 나중에 revoke까지 처리하려면 client secret이 필요하다.
client secret은 Apple Developer의 private key로 직접 서명한 JWT다.
POST https://appleid.apple.com/auth/token client_id={bundle-id-or-service-id} client_secret={developer-signed-jwt} code={authorization-code} grant_type=authorization_code이때
client_id를 틀리면invalid_client가 많이 난다.native iOS 앱에서 받은 identity token은 audience가 Bundle ID인 경우가 많고, 웹 로그인은 Service ID를 쓰는 경우가 많다.
이 둘을 섞으면 정말 오래 헤맨다.
앱 세션 저장
백엔드가 로그인 성공 응답으로 내려주는 값은 우리 서비스 토큰이다.
{ "accessToken": "service-access-token", "refreshToken": "service-refresh-token", "memberId": 1, "isExistingMember": true }앱은 이 값을 Keychain에 저장하고, 이후 API 요청에 붙인다.
Authorization: Bearer {service-access-token}Apple identity token을 계속 API 인증용으로 쓰면 안된다.
자주 막히는 부분
- Apple Developer App ID에는 capability를 켰는데 Xcode capability를 안 켬
- capability 변경 후 provisioning profile이 갱신되지 않음
- 백엔드 audience를 Service ID로 넣었는데 iOS identity token의 aud는 Bundle ID임
- email, fullName이 항상 온다고 가정함
- Apple public key를 가져와도 kid가 달라서 검증 실패하는데 캐시 갱신을 안 함
- authorization code exchange용 .p8 파일을 깃에 올림
Apple login은 설정값이 맞으면 코드가 단순한데, client id / bundle id / service id가 섞이기 시작하면 갑자기 복잡해진다.
처음에는 native iOS 기준으로 Bundle ID 하나를 기준으로 잡고 끝까지 붙이는 것을 추천한다.
최종 정리
Apple login은 iOS에서는
AuthenticationServices, Spring에서는 Apple identity token 검증이 핵심이다.앱은 identity token과 authorization code를 백엔드로 넘기고, 백엔드는 Apple public key로 검증한 뒤 우리 서비스 세션을 발급한다.
email이 안 올 수 있다는 점, audience가 Bundle ID인지 Service ID인지 구분하는 점, private key를 안전하게 보관하는 점만 조심하면 된다.
참고 링크
'기타개발지식 > 풀스택개발' 카테고리의 다른 글
[GitHub][보안]Fine-grained PAT 만들 때 권한과 만료일을 어디까지 줄여야 하나 (0) 2026.06.20 [ChatGPT][업무자동화]Projects와 Custom GPT 차이를 실제 사용 기준으로 정리하기 (0) 2026.06.20 [OpenAI][Structured Outputs] JSON mode와 Structured Outputs 차이를 실무 기준으로 정리하기 (0) 2026.06.20 [iOS][Spring] Google login 붙이기 (0) 2026.06.19 [iOS][Spring] Kakao login 붙이기 (0) 2026.06.19