Apple Auth Implementation Guide

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

RequirementSource
Service IDApple Developer Console → Identifiers
Private key (.p8)Apple Developer Console → Keys
Team IDApple Developer membership details
Key IDMetadata of the generated private key

1. Create a Service Identifier

  1. Open the Apple Developer Console.
  2. Navigate to Identifiers → + and select Service IDs.
  3. Enter the identifier details and enable Sign in with Apple.
  4. 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.