Respiro

Blog Tech Blog About Contact Get in touch

Respiro

Blog Tech Blog About Contact Get in touch
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 :
    • Domain and Subdomain section contains pure domain like: eteo.no
    • Returns urls contains urls value to return user after auth: template: https://eteo.no/login/oauth2/code/apple
    • 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, abcd@privaterelay.appleid.com) 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.
About us

Respiro is an AI-first software company building products that help teams work smarter and move faster.

Company info

Respiro
Oslo, Norway

Company type: Private company

Contact

Email: gt@respiroc.com
Phone: +47 00000000
Contact form


© 2026 Respiro. Registered in the Norwegian Company Register