Implementing Sign in with Apple on the Server Side with Node.js

Behrad Kazemi
5 min readAug 8, 2024

--

Implementing Sign in with Apple on the backend can be challenging due to the lack of comprehensive, step-by-step guides. In this article, I’ll walk you through the process, making it easier for you to integrate Apple’s sign-in feature on your server side.

Before we dive in, set up your Apple Developer account.
Log into your Apple Developer account and navigate to “Certificates, Identifiers & Profiles”. Then create an App ID and Service ID. The Service ID will be used as your clientId.

Generate a new key in the “Keys” menu and enable “Sign in with Apple”.
Download the key file (AuthKey_KEYID.p8) and save it securely.

Once you’re ready, return here to continue.

Overview of Sign in with Apple

When a user signs in with Apple, your client app will receive an authorizationCode and an idToken. Your job on the backend is to validate this authorizationCode and authenticate the user.

Related documentation: Request an Authorization to the Sign in with Apple Server.

URL for Authentication Testing

You can use the following URL to obtain your authCode and idToken for testing your future API:

appleid.apple.com/auth/authorize?response_type=code id_token&nonce=[A_HASH_FOR_YOUR_VALIDATION]&client_id=[YOUR_CLIENT_ID]&redirect_uri=[YOUR_CALLBACK_URI]&state=[SOMETHING_AS_STATE]&scope=email&response_mode=form_post

Backend Setup

To implement the sign-in feature in your backend, you’ll need the following credentials:

export type AppleSignInCredentials = {
privateKey: string;
teamId: string;
keyId: string;
clientId: string;
}

Required Libraries

Before implementing, install the necessary libraries: jose for working with JWT and axios for making HTTP calls:

npm install axios jose qs 

Creating the AppleAuthHandler Class

We’ll implement a class named AppleAuthHandler as follows:

export class AppleAuthHandler {
private readonly _credentials: AppleSignInCredentials;
clientSecret?: string;
static publicKeys: JWK[] = [];

constructor(credentials: AppleSignInCredentials) {
this._credentials = credentials;
}
// ... (other methods)
}

Generating a Client Secret

To communicate with Apple’s server, you need to generate a clientSecret. Here’s how you can do it:

async updateClientSecret(): Promise<string> {
try {
const currentTime = Math.floor(Date.now() / 1000);
const privateKey = await importPKCS8(this._credentials.privateKey, 'ES256');

// Create and sign the JWT
const jwt = await new SignJWT({
iss: this._credentials.teamId,
iat: currentTime,
exp: currentTime + 60 * 60 * 24, // 1 day expiration
aud: "https://appleid.apple.com",
sub: this._credentials.clientId
})
.setProtectedHeader({ alg: 'ES256', kid: this._credentials.keyId })
.sign(privateKey);

this.clientSecret = jwt;
return jwt;
} catch (error) {
console.log('Error creating client secret:', error);
throw error;
}
}

Fetching Apple’s Public Keys

To perform further validations, you need to fetch Apple’s public keys:

private static async updatePublicKey(): Promise<JWK[]> {
try {
const url = 'https://appleid.apple.com/auth/oauth2/v2/keys';
const { status, data } = await axios.get(url);

if ([200, 201, 204].includes(status)) {
const keys = data.keys;
if (keys && keys.length > 0) {
AppleAuthHandler.publicKeys = keys;
return keys;
} else {
console.log('No keys found in the response.');
throw new Error('No keys found in the response');
}
}

throw {
message: 'Failed to execute HTTP request',
status,
data,
};

} catch (error) {
console.error('Error fetching keys:', error);
throw error;
}
}

Validating User Credentials

To validate the incoming authorizationCode, we need to exchange it for an idToken and get user information from Apple’s server. Implement the retrieveToken function:

private async retrieveToken(authorizationCode: string): Promise<AppleSignInTokenResponse> {
if (!this.clientSecret) {
throw new Error('Client secret not set. Please call initialize() first.');
}

try {
const requestData = qs.stringify({
'client_id': this._credentials.clientId,
'client_secret': this.clientSecret,
'code': authorizationCode,
'grant_type': 'authorization_code'
});

const { data, status } = await axios({
method: 'post',
url: 'https://appleid.apple.com/auth/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Axios/1.2.0',
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br'
},
data: requestData
});

if ([200, 201, 204].includes(status)) {
return {
idToken: data.id_token,
refreshToken: data.refresh_token,
accessToken: data.access_token,
tokenType: data.token_type,
expiresIn: data.expires_in
};
}

throw {
message: 'Failed to execute HTTP request',
status,
data,
};
} catch (error) {
console.error('Error retrieving token:', error.response.data);
throw error.response.data;
}
}

Note: Ensure the request content type is application/x-www-form-urlencoded.

Decoding and Validating the idToken

When you get Apple’s public keys, you’ll receive a couple of keys. Select the proper public key based on the kid of your idToken. Here’s how you can do it:

Related documentation: Fetch Apple’s public key for verifying token signature.

static async getPublicKey(kid: string): Promise<JWK> {
if (AppleAuthHandler.publicKeys.length === 0) {
await AppleAuthHandler.updatePublicKey();
}

const foundKey = AppleAuthHandler.publicKeys.find(key => key.kid === kid);
if (foundKey) {
return foundKey;
}

await AppleAuthHandler.updatePublicKey();
const foundKeySecondAttempt = AppleAuthHandler.publicKeys.find(key => key.kid === kid);

if (foundKeySecondAttempt) {
return foundKeySecondAttempt;
}

throw new Error('AppleAuthHandler: Public key not found');
}

Decoding the idToken

Now, let’s decode and validate the retrieved idToken:

private static async decodeIdToken(idToken: string) {
if (AppleAuthHandler.publicKeys.length === 0) {
throw new Error('Apple PublicKey not set. Please call initialize() first.');
}

const idTokenHeader = await decodeProtectedHeader(idToken);
const relatedPublicKey = await AppleAuthHandler.getPublicKey(idTokenHeader.kid);
const publicKey = await importJWK(relatedPublicKey);
const { payload } = await jwtVerify(idToken, publicKey);

return payload;
}

Validate and Return User Information

Finally, validate the decoded token and return the user information:

public async validateUserCredentials({ authorizationCode, nonce }: { authorizationCode: string, nonce?: string }): Promise<AppleUserRetrievedData> {
try {
const tokenResponse = await this.retrieveToken(authorizationCode);
const decodedToken = await AppleAuthHandler.decodeIdToken(tokenResponse.idToken);
const { iss, sub, aud, email, email_verified, is_private_email, nonce } =
decodedToken;

// Validate token properties
this.validateTokenProperties(iss, aud, sub, email_verified, clientNonce, nonce);

return {
aud,
email,
emailVerified,
isPrivateEmail,
sub,
};
} catch (error) {
throw error;
}
}


private validateTokenProperties(
iss: string,
aud: string,
sub: string,
emailVerified: boolean,
nonce?: string,
clientNonce?: string,
): void {
if (!iss.includes('https://appleid.apple.com')) {
throw new AppleAuthError('Token issuer is invalid.');
}
if (aud !== this.credentials.clientId) {
throw new AppleAuthError('Token audience is invalid.');
}
if (!sub) {
throw new AppleAuthError('Token subject is invalid.');
}
if (!emailVerified) {
throw new AppleAuthError('Email not verified.');
}
if (nonce && nonce !== clientNonce) {
throw new AppleAuthError('Nonce is invalid.');
}
}

use the payload.sub as unique id to save or retrieve user information.

Conclusion

Congratulations! You now have the user information from Apple’s sign-in process. Validate the necessary details and proceed with your application’s logic.

I published the refined library to NPM:

npm install third-auth

you can visit the repository for more information and the usage.

Don’t forget to like this article because I probably saved you a couple of days! 😀 Trust me.

--

--