Implementing Sign in with Apple in ASP.NET Core

Scott Brady
Scott Brady
OpenID Connect ・ Updated October 2021
A black button with the text 'Sign in with Apple'

“Sign In With Apple” (SIWA) is Apple’s response to social authentication methods, similar to google. Released as part of Apple’s WWDC 2019 conference, Apple has weighed into the identity provider space by using Apple ID for username and password authentication and MFA using the user’s registered Apple devices.

Sign in with Apple gives you a new alternative to other social login providers such as Google and Facebook. However, unlike those services, it has a greater focus on identity and authentication rather than access to services such as Google calendar.

Their primary value add is the ability to create a “private relay email”. So instead of giving a website your actual email address, you give the website an Apple email address explicitly created for that website. Apple will then forward any emails sent to that private relay email, allowing you to use the website without necessarily exposing your personal information.

In this article, I’m going to take a brief look at how Sign in with Apple fits together (spoiler alert, it’s OpenID Connect) and then show an example integration using ASP.NET Core’s OpenID Connect authentication handler.

Skip to the code.

Sign in with Apple: How It Works

The good news is, Sign in with Apple now implements OpenID Connect. It has an authorization endpoint, a token endpoint, you send it a client ID, redirect URI, state, and at the end of it, you get an identity token in return. However, it does have a few caveats.

Discovery

Unlike the early days, Sign in with Apple now supports the OpenID Connect discovery document, available at https://appleid.apple.com/.well-known/openid-configuration. This endpoint describes important data such as Apple’s:

  • issuer value (iss claim)
  • authorization and token endpoints
  • JWKS URI
  • parts of the OpenID Connect specification it supports.

From my experience, this endpoint is good for loading in endpoint configuration, but the “supported” values are not 100% true. For instance, Sign in with Apple does support the hybrid flow and the c_hash claim, but its discovery document does not reflect this.

Apple ID Discovery Document (October 2021)

{
 "issuer": "https://appleid.apple.com",
 "authorization_endpoint": "https://appleid.apple.com/auth/authorize",
 "token_endpoint": "https://appleid.apple.com/auth/token",
 "jwks_uri": "https://appleid.apple.com/auth/keys",
 "response_types_supported": [
  "code"
 ],
 "response_modes_supported": [
  "query",
  "fragment",
  "form_post"
 ],
  "subject_types_supported": [
  "pairwise"
 ],
 "id_token_signing_alg_values_supported": [
  "RS256"
 ],
 "scopes_supported": [
  "openid",
  "email",
  "name"
 ],
 "token_endpoint_auth_methods_supported": [
  "client_secret_post"
 ],
 "claims_supported": [
  "aud",
  "email",
  "email_verified",
  "exp",
  "iat",
  "iss",
  "sub"
 ]
}

Authorization Request

The authorization URL is the same for every application, but to use it, you must register your client in Apple’s developer portal as a Service ID.

Example Sign in with Apple Authorization Request

Here is an example authorization request your client application can make to Apple (with added line breaks & URL decoded):

https://appleid.apple.com/auth/authorize
  ?client_id=com.scottbrady91.test.sid
  &redirect_uri=https://www.scottbrady91.com/signin-apple
  &response_type=code id_token
  &scope=openid email name
  &response_mode=form_post
  &nonce=xyz
  &state=123

This is your average OpenID Connect authorization request, using the hybrid flow and nonce validation for defense against code injection.

Since Sign in with Apple does not currently support Proof-Key for Code Exchange (PKCE), I recommend using the hybrid flow rather than the authorization code flow. Using the hybrid flow means that you will receive an identity token alongside the authorization code, which you can validate before using your client secret to swap the authorization code for tokens. If you validate the nonce and c_hash values, you can prove that both the identity token and the authorization code were issued in response to your request.

Scopes

Sign in with Apple supports the following scopes:

  • openid
  • name
  • email

Rather than include the identity data from email and name inside the identity token (a bad idea) or support the user info endpoint (a good idea), Apple will return the user’s identity data inside the “user” field of the authorization response. However, they will include the email scope claims in the identity token (email and email_verified).

If you request the name or email scope, Apple requires you to use the form_post response mode. This is because they will not return user data via the URL. Since Apple does not currently support PKCE, the form post response mode is our preferred response mode to prevent the authorization code from being exposed to the URL. Just be aware of how the browser handles any correlation cookies.

Authorization Response

In response to your authorization request, Apple will return the requested code and identity token, repeat your state back to you and their custom “user” property.

POST /callback HTTP/1.1
Host: client.example.org
Content-Type: application/x-www-form-urlencoded

  state=CfDJ8DVJGD9QYONEhBWYFcZNNaJQFu2PU0wnHL2mM9LiJU5TWtJTw0Zurw1mYN3C0W8OPZA3jXo2zG9Me0QpoumPQN3QffOVHuWe1_KsImiDJyhKsVWnu7QVYT5yK_y0fdxIZGx4MA51VaF_zf724ZPGvMNF08WJHRLWcb-kX55Y3LGFN2vFffww2M1ZXF8T5vZmJvzLepdXpFZ2P2nZKNPt68Ubn7Nvrt67W1cQ3HaOKmjpO9wBbXTJ1_iuRAYqGr1rs9BnwyHYbNPs7_9bIed37BNz637MLea3vQoOQEQv2BjFIhGyXK8UsXZuZ0RMhjyJtQ
  &code=c10e4f622debe44b8978e8b33c8138377.0.mvrr.skXKoXGyGRJUpTOpJ9V79w
  &id_token=eyJraWQiOiI4NkQ4OEtmIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNjb3R0YnJhZHk5MS50ZXN0LnNpZCIsImV4cCI6MTYxOTkzOTQ1MSwiaWF0IjoxNjE5ODUzMDUxLCJzdWIiOiIwMDA1MTEuNWM1NDVkOWI4NDUwNDM3ZTk2YTk5YmNiNzI4OTRiZmQuMTQ0MCIsIm5vbmNlIjoiNjM3NTU0NDk3OTkzNDU1ODMyLk1EWXlNV1k1WXprdFlXTXpaUzAwTURWa0xXRmxNRFF0TnpJelptRTVPVEprTlRobE5EQTBaakl5WkRZdFkyUmlNQzAwTnpBMkxUZzVOVE10Tm1ReE1Ua3dORE0xWmpVeSIsImNfaGFzaCI6InNFSlY0S3UtRUQ1ZDVsQ3B3TTFsbEEiLCJlbWFpbCI6InNjb3R0YnJhZHk5MUBvdXRsb29rLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjoidHJ1ZSIsImF1dGhfdGltZSI6MTYxOTg1MzA1MSwibm9uY2Vfc3VwcG9ydGVkIjp0cnVlfQ.JCxDKZN7JFcRu6FTmwCzusn_VvH-E5H4Smd9pm0oZVgqP4lkIycWHvqA3GA7uwnseQHMre8c_rfQxIECkfLz9PUrlue8nYJpvKrWoy3MeK8-c_xA9D8n4lSEoaOok7MDsxSVIP8C12HNU3AaQr1pFh4PJjj6ktmzjnCohaarwFNZelIeDc9heHN-pfc1gr2fCpyit_SHfMaYakiAKzY9RCOM0JJnAbgFR_fAdSIzcV9wSj-Vp3u5c5jV2CvTK_y76qM8lwHzVh3zAZsQvmd1MIdDin4AKlAaV225odufX-o0BVyLeLjbjbLpq8zSMaQT16UAaA_XlXP0Ur3hnH15jw
  &user={"name":{"firstName":"Scott","lastName":"Brady"},"email":"[email protected]"}

Note that you will only receive the user property the first time that the user uses your client application. After that, apple will not repeat the user data, so if you want to remember it, now is the time to save it. If something goes wrong, the only way I’ve found to reset the process is to have the user visit https://appleid.apple.com/account/manage, revoke consent, and reauthenticate into your app.

Token Request

Apple does not support shared secrets for client authorization. Instead, they use a custom implementation, similar to JWT Bearer Token for Client Authentication found in RFC 7523.

To authenticate the client, you’ll need to generate a JWT, but instead of signing it with a key known only to you, you must instead use a private key created by Apple in their developer portal. You then use this key to sign your JWT using the ES256 signing algorithm.

This JWT must also have:

  • An issuer (iss) value set to your Apple Team ID (found in the membership area of the Apple developer portal)
  • An expiry (exp) of under 6 months (allowing for long-lived or short-lived credentials)
  • An audience (aud) value of https://appleid.apple.com
  • A subject (sub) value equal to your client_id (Service ID)

You can find more details on the client secret type in Apple’s documentation.

Example JWT Secret

You client secret will look something like the following JWT:

Encoded
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb20uc2NvdHRicmFkeTkxLnRlc3Quc2lkIiwibmJmIjoxNjE5ODUzMDQ3LCJleHAiOjE2MTk4NTM3MDcsImlhdCI6MTYxOTg1MzA0NywiaXNzIjoiWjVVN04yUlgyRCIsImF1ZCI6Imh0dHBzOi8vYXBwbGVpZC5hcHBsZS5jb20ifQ.EWKGm0cPBpThnxQMBbcQ1UmupoXG-_Hz7tBdTOg3TlMKDrNNPUWHnxWR3qWEvcdNNmzTKQ_cjxtjvLHfFGCrRw
Decoded
{
  "alg": "ES256",
  "typ": "JWT"
}
{
  "sub": "com.scottbrady91.test.sid",
  "nbf": 1619853047,
  "exp": 1619853707,
  "iat": 1619853047,
  "iss": "Z5U7N2RX2D",
  "aud": "https://appleid.apple.com"
}

Example Sign in with Apple Token Request

Otherwise, the token request looks the same as your usual OAuth token request, but the client_secret value is your JWT.

POST https://appleid.apple.com/auth/token HTTP/1.1
User-Agent: Microsoft ASP.NET Core OpenIdConnect handler
Content-Type: application/x-www-form-urlencoded

client_id=grant_type=authorization_code
    &code=c10e4f622debe44b8978e8b33c8138377.0.mvrr.skXKoXGyGRJUpTOpJ9V79w
    &redirect_uri=https://www.scottbrady91.com/signin-apple
    &com.scottbrady91.test.sid
    &client_secret=eyJhbGciOiJFU[...]skXKoXGyGRJUpTOpJ9V79w

Note that Apple’s token endpoint requires a User-Agent header to succeed validation.

Example Sign in with Apple Token Response

In response to your token request, Apple will respond with your three tokens:

{
    "access_token": "a3968ed1eb41a4209926dfc25a31302ad.0.mvrr.IA7gjyWKIU-4Ixi-tJYd-Q",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "rdddf5fe9418e4af5b6d07fb6e7ff2065.0.mvrr.o9f5eqciLvnulFkd5Dw6zQ",
    "id_token": "eyJraWQiOiJZdXlYb1kiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNjb3R0YnJhZHk5MS50ZXN0LnNpZCIsImV4cCI6MTYxOTkzOTU2MCwiaWF0IjoxNjE5ODUzMTYwLCJzdWIiOiIwMDA1MTEuNWM1NDVkOWI4NDUwNDM3ZTk2YTk5YmNiNzI4OTRiZmQuMTQ0MCIsIm5vbmNlIjoiNjM3NTU0NDk3OTkzNDU1ODMyLk1EWXlNV1k1WXprdFlXTXpaUzAwTURWa0xXRmxNRFF0TnpJelptRTVPVEprTlRobE5EQTBaakl5WkRZdFkyUmlNQzAwTnpBMkxUZzVOVE10Tm1ReE1Ua3dORE0xWmpVeSIsImF0X2hhc2giOiJBN1c2eXhqdnJBdjFwVElwWUlBM3JRIiwiZW1haWwiOiJzY290dGJyYWR5OTFAb3V0bG9vay5jb20iLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJhdXRoX3RpbWUiOjE2MTk4NTMwNTEsIm5vbmNlX3N1cHBvcnRlZCI6dHJ1ZX0.HTsvmNZrrvjCSSgO9eoyqkm4LV2u-yjqN1st7TnxEoI3VekXCY_BGaRyJ8Rn2MH5Ge3RbjLhpx7khzrCvFRsb1UGZHTuczGZDsBYPPDHtEaMnylDtnhjXSI0oQOg2BHAI26flbDy3yuRH2GxZN5DxEsz2RRUSuJuK7U5DIMdYYErTg_LScImvRhzFzAbRlimYiwxBFfRNTMkjCCemBo0L43f7GYS73lVnGCswG61vrnTH2OKctuIDSfJCbkBYVlkqqTSOjoSx7e_3OgDvWDwajAbKJLYypC_5_TmjraluPisk3V2dGybSt-gALS6P2_WtqwsZ4sfcrNQOPU-B_Aipg"
}

ASP.NET Core Authentication using Sign in with Apple

Let’s see how to integrate this in ASP.NET Core. First, you’ll need to do some setup within Apple’s developer portal, access to which will, unfortunately, put you back at least £79 if you don’t already have an account.

Apple Developer Portal Setup

Apple has some decent step-by-step instructions for creating a client application in their developer portal; however, the requirements boil down to:

  1. Create an App ID configured for “Sign in with Apple”
  2. Create a Service ID configured for “Sign in with Apple” (including a verified domain and redirect URIs)
  3. Create a key that can be used by your created Service ID

Unfortunately, you can’t use localhost redirect URIs, but you can use dev domains such as .test. In my example, I set my verified domain as www.scottbrady91.com and had my redirect URL as http://local.test:5000/signin-apple (with an associated host record on my machine).

ASP.NET Core Integration

Let’s start by adding cookie and OpenID Connect authentication handlers to your application’s Startup class, making it look something like the following:

services.AddAuthentication(options =>
    {
        options.DefaultScheme = "cookie";
        options.DefaultChallengeScheme = "apple";
    })
    .AddCookie("cookie")
    .AddOpenIdConnect("apple", async options =>
    {
        options.ResponseType = "code id_token"; // hybrid flow due to lack of PKCE support
        options.ResponseMode = "form_post"; // form post due to prevent PII in the URL
        options.UsePkce = false; // apple does not currently support PKCE (April 2021)
        options.DisableTelemetry = true;

        options.Scope.Clear(); // apple does not support the profile scope
        options.Scope.Add("openid");
        options.Scope.Add("email");
        options.Scope.Add("name");

        // TODO
    });

Don’t forget to add the call to UseAuthentication in your Configure methodand forcing authentication on one of your routes (e.g. using an AuthorizeAttribute).

Like you saw earlier, you’re using the hybrid flow, relying on c_hash and nonce validation to prevent code or identity token injection.

You’ve also cleared the scope collection to prevent ASP.NET from asking for the profile scope, which Apple does not support (it used to cause an HTTP 500 error on Apple’s consent page).

Since Apple now supports OpenID Connect’s discovery document, you can configure appleid.apple.com as the authority, allowing ASP.NET Core to automatically load the discovery document and Apple’s public keys (from their JWKS endpoint). In addition, ASP.NET Core will periodically reload this metadata, allowing it to automatically load in new public keys whenever Apple triggers key rollover.

options.Authority = "https://appleid.apple.com";

Your client_id will be the Service ID that you created for Sign in with Apple. Your callback path must match a redirect URI configured in that Service (remember, this is case-sensitive).

options.ClientId = "com.scottbrady91.authdemo.service"; // Service ID
options.CallbackPath = "/signin-apple"; // corresponding to your redirect URI

Now for secret generation. When you generate a key within the Apple developer portal, it is given to you as a .p8 file. Luckily, this is something you can read with relative ease, thanks to the ImportPkcs8PrivateKey method on ECDsa.

public static class TokenGenerator
{
    public static string CreateNewToken()
    {
        const string iss = "62QM29578N"; // your account's team ID found in the dev portal
        const string aud = "https://appleid.apple.com";
        const string sub = "com.scottbrady91.authdemo.service"; // same as client_id
        var now = DateTime.UtcNow;

        // contents of your .p8 file
        const string privateKey = "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgnbfHJQO9feC7yKOenScNctvHUP+Hp3AdOKnjUC3Ee9GgCgYIKoZIzj0DAQehRANCAATMgckuqQ1MhKALhLT/CA9lZrLA+VqTW/iIJ9GKimtC2GP02hCc5Vac8WuN6YjynF3JPWKTYjg2zqex5Sdn9Wj+";
        var ecdsa = ECDsa.Create();
        ecdsa?.ImportPkcs8PrivateKey(Convert.FromBase64String(privateKey), out _);
        
        var handler = new JsonWebTokenHandler();
        return handler.CreateToken(new SecurityTokenDescriptor
        {
            Issuer = iss,
            Audience = aud,
            Claims = new Dictionary<string, object> {{"sub", sub}},
            Expires = now.AddMinutes(5), // expiry can be a maximum of 6 months - generate one per request or re-use until expiration
            IssuedAt = now,
            NotBefore = now,
            SigningCredentials = new SigningCredentials(new ECDsaSecurityKey(ecdsa), SecurityAlgorithms.EcdsaSha256)
        });
    }
}

Using this helper method, you can generate a new client secret per request via the OnAuthorizationCodeReceived event:

// custom client secret generation - secret can be re-used for up to 6 months
options.Events.OnAuthorizationCodeReceived = context =>
{
    context.TokenEndpointRequest.ClientSecret = TokenGenerator.CreateNewToken();
    return Task.CompletedTask;
};

You should now be able to login using “Sign in with Apple”

Source Code

You can find a working sample of this code on GitHub. You’ll just need to provide your own Service ID, redirect URI, and signing key.

Further Reading