Apple Auth Implementation
Learn how to implement Sign in with Apple in a Spring Boot application using OAuth2 and JWT-backed client secrets. Follow the steps below to configure identifiers, register the OAuth2 client, and handle the authentication flow end to end.
Overview
Requirement | Source |
---|---|
Service ID | Apple Developer Console → Identifiers |
Private key (.p8) | Apple Developer Console → Keys |
Team ID | Apple Developer membership details |
Key ID | Metadata of the generated private key |
1. Create a Service Identifier
- Open the Apple Developer Console.
- Navigate to
Identifiers → +
and select Service IDs. - Enter the identifier details and enable Sign in with Apple.
- Switch to the Keys tab, create a new key, associate it with the service, and download the .p8 file. Apple only allows a single download, so store it securely.
2. Configure Spring Boot Properties
Add the Apple OAuth2 configuration to application.properties
:
spring.security.oauth2.client.registration.apple.client-id=[Service ID]
spring.security.oauth2.client.registration.apple.client-name=Apple
spring.security.oauth2.client.registration.apple.scope=name,email
spring.security.oauth2.client.registration.apple.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.apple.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.apple.client-authentication-method=client_secret_post
spring.security.oauth2.client.provider.apple.authorization-uri=https://appleid.apple.com/auth/authorize
spring.security.oauth2.client.provider.apple.token-uri=https://appleid.apple.com/auth/token
spring.security.oauth2.client.provider.apple.jwk-set-uri=https://appleid.apple.com/auth/keys
apple.team-id=[Team ID]
apple.key-id=[Key ID]
apple.private-key=-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...\n-----END PRIVATE KEY-----
3. Generate the Client Secret Dynamically
Apple validates requests with a JWT-based client secret. The following Spring component uses the JDK 25 PEM decoder and JJWT:
@Component
public class AppleClientSecretGenerator {
@Value("${apple.team-id}")
private String teamId;
@Value("${apple.key-id}")
private String keyId;
@Value("${apple.private-key}")
private String privateKeyPem;
@Value("${spring.security.oauth2.client.registration.apple.client-id}")
private String clientId;
public String generateClientSecret() {
Date now = new Date();
Date expiration = new Date(now.getTime() + 3600 * 1000);
return Jwts.builder()
.setHeaderParam("kid", keyId)
.setIssuer(teamId)
.setSubject(clientId)
.setAudience("https://appleid.apple.com")
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(getPrivateKey(), SignatureAlgorithm.ES256)
.compact();
}
private PrivateKey getPrivateKey() {
try {
Optional<PEMDecoder> decoder = PEMDecoder.of(privateKeyPem.toCharArray());
if (decoder.isEmpty()) {
throw new IllegalArgumentException("Cannot create PEMDecoder");
}
Object parsedObject = decoder.get().decode();
if (parsedObject instanceof KeyPair keyPair) {
return keyPair.getPrivate();
}
throw new IllegalStateException("Cannot parse private key");
} catch (Exception e) {
throw new RuntimeException("Cannot handle private key", e);
}
}
}
4. Register the OAuth2 Client Programmatically
Spring needs the fresh client secret every time the application starts, so build the Apple registration in code:
@Configuration
public class OAuth2ClientConfig {
private final AppleClientSecretGenerator appleClientSecretGenerator;
private final OAuth2ClientProperties oAuth2ClientProperties;
public OAuth2ClientConfig(AppleClientSecretGenerator generator, OAuth2ClientProperties properties) {
this.appleClientSecretGenerator = generator;
this.oAuth2ClientProperties = properties;
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
List<ClientRegistration> registrations = oAuth2ClientProperties.getRegistration().entrySet().stream()
.map(entry -> {
if ("apple".equals(entry.getKey())) {
return createAppleClientRegistration(entry.getValue());
}
return org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter
.getClientRegistration(entry.getValue(), entry.getKey());
})
.filter(Objects::nonNull)
.toList();
return new InMemoryClientRegistrationRepository(registrations);
}
private ClientRegistration createAppleClientRegistration(OAuth2ClientProperties.Registration props) {
return ClientRegistration.withRegistrationId("apple")
.clientId(props.getClientId())
.clientSecret(appleClientSecretGenerator.generateClientSecret())
.clientAuthenticationMethod(new ClientAuthenticationMethod(props.getClientAuthenticationMethod()))
.authorizationGrantType(new AuthorizationGrantType(props.getAuthorizationGrantType()))
.redirectUri(props.getRedirectUri())
.scope(props.getScope())
.authorizationUri("https://appleid.apple.com/auth/authorize?response_mode=form_post")
.tokenUri("https://appleid.apple.com/auth/token")
.clientName(props.getClientName())
.build();
}
}
5. Decode the Apple ID Token
Apple embeds user data in the ID token instead of providing a profile endpoint. Parse it with a custom OAuth2UserService
:
@Service
public class AppleOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
String jwkSetUri = userRequest.getClientRegistration().getProviderDetails().getJwkSetUri();
JwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
Jwt jwt = jwtDecoder.decode(idToken);
Map<String, Object> claims = jwt.getClaims();
return new DefaultOAuth2User(
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")),
claims,
"sub"
);
}
}
6. Extend the Security Configuration
Wire the custom user service into the security filter chain:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {
private final AppleOAuth2UserService appleOAuth2UserService;
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> delegatingOAuth2UserService() {
return userRequest -> {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
if ("apple".equalsIgnoreCase(registrationId)) {
return appleOAuth2UserService.loadUser(userRequest);
}
return new DefaultOAuth2UserService().loadUser(userRequest);
};
}
@Bean
@Order(2)
public SecurityFilterChain webSecurityFilterChain(HttpSecurity http,
UserService userService,
GoogleOAuth2SuccessHandler successHandler) throws Exception {
return http
.oauth2Login(oauth2 -> oauth2
.successHandler(successHandler)
.loginPage("/auth/login")
.userInfoEndpoint(userInfo -> userInfo.userService(delegatingOAuth2UserService()))
)
.build();
}
}
7. Handle the OAuth2 Success Event
Apple sends profile data only once, so capture it immediately in a custom success handler:
@Component
class CustomOAuth2SuccessHandler(
private val userService: UserService,
private val jwtService: JwtService
) : AuthenticationSuccessHandler, BaseController() {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val oauth2User = authentication.principal as OAuth2User
val email = oauth2User.attributes["email"] as? String
?: throw IllegalStateException("Email not found in OAuth2 response")
val registrationId = (authentication as OAuth2AuthenticationToken).authorizedClientRegistrationId
var firstName: String? = null
var lastName: String? = null
if ("apple".equals(registrationId, ignoreCase = true)) {
val userJson = request.getParameter("user")
if (!userJson.isNullOrBlank()) {
firstName = ...
lastName = ...
}
} else if ("google".equals(registrationId, ignoreCase = true)) {
firstName = oauth2User.attributes["given_name"] as? String
lastName = oauth2User.attributes["family_name"] as? String
}
val user = userService.loginOrSignupOAuth2(email, firstName, lastName)
val token = jwtService.generateToken(subject = user.id.toString(), tenantId = user.tenantId)
setJwtCookie(token, response)
response.sendRedirect(target)
}
}
Key Points and Pitfalls
- response_mode=form_post is required; otherwise Apple omits the ID token.
- First and last names appear only during the first authorization. Persist them even if subsequent logins return
null
. - Private Relay addresses (for example,
[email protected]
) are valid and should be accepted. - Rotate the client secret frequently. The code above generates a short-lived token when the context starts; adjust if you need per-request rotation.