Sign-in with Apple is a fantastically useful new feature in iOS 13 and macOS Catalina. I knew when it was announced that I would want to support it. I’m currently working on this for NSScreencast and things haven’t gone quite as smooth as I would have expected.

Update: Thankfully, Apple changed the implementation at some point to include the email address in the signed id_token. See down further for more details. Thanks to Sebastian Hubrich for correcting me on this.

For comparison, I already support Sign-in with Google (as required by some larger companies with team accounts). In this implementation I use the sorcery rubygem which supports several social logins (Google and a few dozen others), but does not currently support Sign-in with Apple (henceforth: “SIWA”).

I looked at providing support for it myself, however I couldn’t seem to follow the patterns that the rest of these plugins follow. The OAuth payload usually contains the requisite profile information, such as first name, last name, email address, etc. With SIWA, it doesn’t, and this presents a few challenges.

Profile Information Only Once

Apple provides the profile information to your application (or website) only once. The callback payload for a web app looks something like this:

{
    "code": "1234781237213123...",
    "id_token": "PEHANCKakenAKDNFAhekqoioenakdfnn53i2kKK23k2k2nn3...",
    "state": "..."
    "user": {
        "name": {
            "firstName": "Kurt",
            "lastName": "Cobain"
        }
    },
    "email": "kurt@example.com"
}

The id_token is a signed JWT that contains information like:

  • Who issued this token?
  • Who is it for?
  • When does it expire?
  • What crypto algorithm can we use to validate it?
  • What is the cryptographic signature of the token to prove it hasn’t been tampered with or created by some other entity?

As you can see the id_token is an important part of this puzzle. If you aren’t familiar with JWTs yet, you will need to read up on them, as it is required knowledge for a SIWA implementation.

Alongside the token is a user object that contains profile information. We requested email address and full name, so those are provided here.  In my case - and I suspect in many implementations - this user profile information is required to sign up for a new account.

So what happens if the user starts the sign in process and then cancels at the last step? What if your app encounters and error and the user has to retry? This user object will not be sent to you.

The docs warn you of this:

Apple only returns the user object the first time the user authorizes the app. Persist this information from your app; subsequent authorization requests won’t contain the user object.

This part is not only a nuisance, but a security concern that you will have to mitigate. More on the security aspect in a minute.

Most implementations will end up saving this profile information to later associate it with a successful sign in. And this produces a failure case you have to handle: What if you have no user hash above and also don’t have any saved profile information? The only recourse is to ask the user to navigate to the Apple ID account settings and remove your app from the list and try again. Ick.

In my case I have a Rails model to save this information in my database:

# Used to save user profile information from Sign-in w/ Apple
# We only get this information once, so if they abandon the sign-up
# process mid-way, we need to be able to retrieve it again if it is missing.
class AppleUserInfo < ApplicationRecord
end

This model has a sub column, which contains the Apple unique user id value, and a jsonb column for the user object above.

So now when a user signs up, I can provide a fallback user hash if the callback payload doesn’t contain one:

def callback(params)
  id_token = params.fetch(:id_token)

  parsed_token = verify(id_token) # decode and verify the authenticity of the JWT

  user_hash = params[:user] || AppleUserInfo.find_by!(sub: parsed_token[:sub]).user_hash

  # if we don't have a user hash here we have a problem and need to ask the user
  # to delete the app from their Apple ID account settings and try again :(

  # ...
end

So if I fall into that last case where I have no user hash saved and can’t fall back, we’re stuck and we can’t continue. So I have to ask the user to remove the app from their Apple ID account settings. This error message is awful, I know. And while it is a rare edge case it’s still technically possible to encounter.

If you have no profile information and you haven't saved any prior, you have to resort to ugly error messages like this one, prompting the user to delete their login and try again.

The user doesn’t have an account on your system, but iCloud thinks they do. So you have to go to Apple ID settings on your device and remove the app from there. Once they do that it will be like a fresh sign-in experience again.

Similar Experience on iOS

On iOS we have a similar experience, though the mechanics are different. Once a user taps a Sign-in with Apple button, you request authorization like this:

let provider = ASAuthorizationAppleIDProvider()
let request = provider.createRequest()
request.requestedScopes = [
    .fullName, .email
]

let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()

Here we request .fullName and .email scopes, so we expect this information to be present when we get the callback:

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {

    if let appleIDCred = authorization.credential as? ASAuthorizationAppleIDCredential {

        let idToken = appleIDCred.identityToken
        let authCode = appleIDCred.authorizationCode

        let userId = appleIDCred.user // gives you Apple's unique id for this user. In the web callback id_token this is called 'sub'
		
        let fullName: PersonNameComponents? = appleIDCred.fullName
        let email: String? = appleIDCred.email

        // ...
    }
}

As you can see here, the credentials we get back from the Apple ID authorization callback include the idToken, as well as fullName and email, but these last two are both optional properties for the reasons mentioned above.

Update: The `idToken` here now includes the email address, so if that's all you need to sign up then you're good. Just read it from the token instead of the credential object directly. Your server will be decoding and verifying this token anyway, providing you with `email` and `email_verified` keys in the payload section of the JWT.

So here we’re in the same boat. We need to save this information somewhere and then fall back to it if it wasn’t provided. In my case I chose the keychain:

KeychainHelper.save(KeychainKeys.userId, data: appleIDCred.user)

if let fullName = appleIDCred.fullName {
    if let givenName = fullName.givenName {
        KeychainHelper.save(KeychainKeys.firstName, data: givenName)
    }
    if let familyName = fullName.familyName {
        KeychainHelper.save(KeychainKeys.lastName, data: familyName)
    }
}

if let email = appleIDCred.email {
    KeychainHelper.save(KeychainKeys.email, data: email)
}

And then we follow a similar flow for falling back to this information when creating an account. Again, we can fall into a case where these don’t exist and have to ask the user to delete the login from their device’s Apple ID settings and try again.

The Security Problem

Update: This problem is now much less of an issue since `email` is part of the `idToken`. I'll leave the description here nonetheless since you still probably want to avoid automatically linking accounts unless you've verified the email address you have is actually theirs.

Earlier I mentioned that the fact that the user information is provided outside the idToken as a sibling node means that we can’t trust it. We can only trust the values embedded in the token, as those are signed with a signature that we can verify. Anything outside of that could be tampered with and your app’s server would not know.

What if an account already existed on your system with the provided email address? You might want to be friendly and allow the user to just link them up and use either credentials to log into the same a count.

Here’s an attack vector:

  • Mary has an account on your system. Her email address is mary@example.com and she logs in with a username and password.
  • Anita knows that Mary has an account. She uses the mobile app and crafts a man-in-the-middle attack, intercepting the traffic coming from the mobile app to your server. Using her own Apple ID, she logs in to create an account. On the outgoing request, she modifies the above user hash to indicate a different email address, in this case mary@example.com.

If we were to allow linking up accounts based on the provided email address, we’d now have allowed Anita to steal Mary’s account.

If the profile information was provided in the JWT itself, Anita would be unable to do this, as modifying the JWT in any way would break the signature and the server would not trust it.

The answer to this situation is to not allow linking up based on email address alone, and demand instead that that user logs in with their password credentials first, then associate with an Apple ID later.

Summary

I originally wrote a TL;DR for this post and then realized the situation is nuanced and requires some explanation. While SIWA is not without it’s faults, the end-user experience is largely a positive one and supporting it is likely worth your time.

I recently released support for SIWA on NSScreencast.com and currently have around 50% of sign-ups using this over username & password. I find this to be quite encouraging, and would recommend you add support for your apps as well.