Sign in with Apple in Rails: the provider with no client secret
September 12, 2026
More on Social sign-in with OmniAuth
- redirect_uri_mismatch: the OAuth callback URL contract
- OmniAuth's CSRF protection: why the request phase is a POST
- Testing OmniAuth in Rails, without touching the provider
- Running ten OAuth providers without ten code paths
- OAuth account linking: the three cases, and the one that bites
- Add Google sign-in to Rails 8, from scratch
Every other OAuth2 provider hands you two strings. You paste a client ID and a client secret into encrypted credentials, and the secret sits there until you rotate it. Apple does not work that way.
The Apple client secret is a JWT your server signs, not a string you store
The Apple client secret is a JSON Web Token your own server signs, which is why there is nothing to copy out of the developer portal and paste into a credentials file. Apple's documentation for creating a client secret sets out the whole claim set: the header carries
algES256 andkid, a 10 character key identifier; the payload carriesiss, the 10 character Team ID that issued the secret,iat,exp,audset tohttps://appleid.apple.com, andsub, which Apple specifies as "the same App ID or Services ID that you use as the client_id", case sensitive. Apple then requires the token to be signed "using the Elliptic Curve Digital Signature Algorithm (ECDSA) with the P-256 curve and the SHA-256 hash algorithm".Two consequences follow for a Rails application. The first is that the thing you store is a private key, not a secret, and every request that needs a secret has to sign one. The second is that even a hand-rolled long-lived token cannot be left alone forever: Apple states it is "an error to request an expiration time more than 15777000 seconds (six months) in the future", so a secret generated by hand is a calendar reminder you will forget. A library that signs on demand removes that problem rather than scheduling it.
What you register with Apple: a Services ID, a Team ID, a Key ID and a .p8 file
A Services ID, a Team ID, a Key ID and a
.p8private key are the four values Sign in with Apple needs on the web, and none of them is called a client secret. The Services ID plays the role of theclient_idfor a web flow; Apple's authorization endpoint documents that identifier as "the App ID or Services ID for your app" and warns that it "must not include your Team ID, to help prevent the possibility of exposing sensitive data to the end user". The Team ID and the Key ID are both 10 character identifiers, and the.p8file is the PEM-encoded elliptic curve private key generated alongside the Key ID.The
.p8matters more than the other three because of how it is issued: the portal offers the download once, and a key you failed to save is a key you revoke and replace rather than one you retrieve. Its contents are what gets stored, not its path, since a deployed application has no filesystem you can rely on for it. In Rails that means the PEM text goes into encrypted credentials next to the other three values, and the three identifiers, which are not secret in any meaningful sense, sit beside it purely so that one lookup returns everything the strategy needs.The Apple redirect URI cannot be localhost, and the callback path is /auth/apple/callback
The Apple redirect URI has rules no other provider imposes, and the one that stops people on day one is the ban on localhost. Apple's authorization documentation states that the URI "must use the HTTPS protocool [sic], include a domain name, can't be an IP address or localhost, and must not contain a fragment identifer (#)". Google is happy to register
http://localhost:3000/..., which is why the Google walkthrough can take you from an empty console to a working button without leaving your machine. Apple cannot, so local development needs an HTTPS tunnel with a real hostname, registered as its own redirect URI.The path itself is the ordinary OmniAuth one.
OmniAuth::Strategies::Appledeclaresoption :name, 'apple', so the request phase is/auth/appleand the callback is/auth/apple/callback. Checking a strategy name against the brand name is a habit worth keeping, because the comparable providers do not match: the Google strategy calls itselfgoogle_oauth2and Microsoft's calls itselfmicrosoft_graph, so the brand name and the path diverge. Apple's strategy name matches its brand, which removes one class of mistake from the portal entry and adds none.Mounting omniauth-apple, and the blank client_secret argument
Mounting
omniauth-applelooks like mounting any other strategy except for the second positional argument, which is deliberately empty:The blank string is required rather than optional.
OmniAuth::Strategies::OAuth2declaresargs %i[client_id client_secret], so the two positional slots are consumed in order and dropping one would shift the options hash into the secret's place. What fills the slot never matters, becauseomniauth-appledefinesclient_secretas a private method that ignores the option and builds a token instead:Sixty seconds of validity, signed with the EC key parsed straight out of the
pemoption, handed to::OAuth2::Client.new(client_id, client_secret, ...)in place of a stored string. The six month ceiling Apple documents never comes near being a problem, because no token from this method lives long enough to be replayed.Apple posts the callback back, because form_post is mandatory with any scope
Apple posts the authorization result to your redirect URI rather than redirecting the browser to it with query parameters, and that is not a preference the gem chose. Apple's documentation lists
query,fragmentandform_postas the valid values ofresponse_modeand then closes the choice: "If you requested any scopes, the value must be form_post." Since asking for the email address is the entire reason to run the flow, form_post is the only mode a real integration uses.What form_post means concretely is spelled out on the same page: "an HTTP POST request containing the results of the authorization is sent to the redirectURI. The HTTP body contains the result parameters with application/x-www-form-urlencoded content type." The gem hardcodes it,
option :authorize_params, response_mode: 'form_post', scope: 'email name', so there is no configuration that turns it off, and the flow stays code-only because theoauth2gem's auth code strategy sets"response_type" => "code"itself.The successful POST body carries
code, a "single-use authorization grant code that's valid for five minutes", plusid_token,state, and on one occasionuser. The failure body carrieserror, and Apple documents exactly one value for the web flow:user_cancelled_authorize, "if the user clicks the cancel button during the web flow".The Rails route for the Apple callback has to accept POST
The Rails route that receives the Apple callback has to accept POST, because OmniAuth hands the original request straight through to your application.
OmniAuth::Strategy#call!dispatches on the path alone,return callback_call if on_callback_path?, with no check on the verb, andcallback_phasethen doesenv['omniauth.auth'] = auth_hashfollowed bycall_app!. Your router sees a POST to/auth/apple/callback.Almost every Rails OmniAuth setup, this codebase included, declares that route the way every tutorial shows it:
That line matches Google, GitHub, GitLab, Microsoft, Discord and the rest, because they all redirect the browser back with a GET. It does not match Apple. Accepting both verbs on one route is the change Apple forces:
Routing is only half of it. Rails 8 turns on forgery protection by default,
load_defaults 8.1carriesaction_controller.default_protect_from_forgery = true, and a POST arriving from appleid.apple.com carries no authenticity token of yours. A controller action that receives a provider callback has to skipverify_authenticity_token, which is safe for the same reason it is safe on a GET callback: the request phase is where login CSRF is stopped, andomniauth-rails_csrf_protectionstops it there by forcing that phase to be a POST from your own form.The OmniAuth state check reads a session cookie the Apple POST does not carry
The OmniAuth state check fails on an Apple callback for a reason that has nothing to do with Apple's state parameter being wrong.
omniauth-oauth2writes the value into the session during the request phase,session["omniauth.state"] = params[:state], and compares it on the way back:The comparison needs the session, and the session needs the cookie. Rails sets
action_dispatch.cookies_same_site_protection = :laxin its defaults, and a browser applying Lax does not attach the cookie to a cross-site POST. So the session the callback reads is a fresh empty one,session.delete("omniauth.state")returns nil, and the request fails ascsrf_detectedbefore the token is ever exchanged. The same cookie holds the nonce:omniauth-applewritessession['omniauth.nonce']during the authorize step and reads it back withsession.delete, which it needs whenever the identity token declaresnonce_supported.Reports of this land as a 422 rather than as a sign-in failure. The gem's own issue tracker carries it as issue 114, "Help with ActionController::InvalidAuthenticityToken error", still open at the time of writing.
Weighing the workarounds for the missing session, none of which is free
Workarounds for the missing session cookie exist, and every one of them gives something up in exchange for the callback completing.
provider_ignores_stateis a realomniauth-oauth2option, declared asoption :provider_ignores_state, false, and setting it true removes the comparison quoted above entirely. Removing a check is not the same as making it work: what you get back is a callback that no longer verifies that the response belongs to a flow this browser started, and nothing in the gem replaces it.Relaxing the cookie is the other direction. Rails lets you set the SameSite attribute on the session cookie, which makes the browser send it on the cross-site POST and keeps the state check meaningful, at the cost of widening when that cookie travels for every other request the application serves. Forks of
omniauth-appletake a third route and store the nonce outside the session; version 1.4.0 as published has no such option, so that path means running someone else's fork.The honest summary is that the mechanism Apple chose, a cross-site POST, is in tension with the mechanism OmniAuth chose, session-stored state, and every available answer moves the problem somewhere else. Knowing which of the three you have picked, and why, is the part worth writing down next to the initializer.
Apple sends the user's name once, as an unsigned form field
The name Apple sends arrives exactly once, on the first authorization, and never again. Apple states it plainly: "Apple only returns the user object the first time the user authorizes the app. Validate and persist this information from your app to your server. Subsequent authorization requests do not contain the user object, however, the user's email is provided in the identity token for all requests." The shape is
{ "name": { "firstName": string, "lastName": string }, "email": string }, delivered as auserform field in the POST body.Where it comes from matters as much as how often. Apple warns that "the raw data is passed directly to your app from the browser and is not included in the user's identity token", and asks you to "validate and sanitize the user-submitted first and last name values before storing on your app servers". The gem mirrors the split precisely:
first_nameandlast_namereaduser_info.dig('name', ...), which parsesrequest.params['user'], whileemailreadsid_info[:email]from the verified token. One of those is signed by Apple and one is a string the browser posted.The practical rule that falls out is that a callback which does not persist the name on that first request has thrown it away. There is no endpoint to ask again.
What omniauth-apple verifies on the Apple identity token
The Apple identity token is verified by the gem rather than trusted, which is the part worth checking in any strategy that parses a JWT.
omniauth-appledecodes the token with signature verification skipped, reads itskid, fetches Apple's JWK set fromhttps://appleid.apple.com/auth/keysfor that key id, and only then verifies the signature against it. A fetch failure raisesjwks_fetching_failedand a bad signature raisesid_token_signature_invalid, so neither degrades into an accepted token.Claims are checked after the signature:
issmust equalhttps://appleid.apple.com,audmust be the configuredclient_idor one ofauthorized_client_ids,iatmust not be in the future,expmust not be in the past, and the nonce is compared against the session value when, and only when, the token carriesnonce_supported. TheuidOmniAuth reports is the token'ssubclaim.Keying your identity records on that
subrather than on the email address is the same rule every provider follows, and it earns its keep here for an extra reason: an Apple user can switch from a real address to a relay address, or stop forwarding one, without theirsubchanging at all.Apple private relay addresses, and what they do to your mailer
Apple private relay addresses are ordinary-looking email addresses the user gets instead of their real one, and Apple documents that they "end in @private.icloud.com, @privaterelay.appleid.com, or @icloud.com". They route to one of the Apple Account's verified addresses, they are the same for a user across every app written by a single development team, and they are different for that same user across apps written by different teams. The gem surfaces two flags alongside the address,
is_private_emailandemail_verified, both read from the verified identity token.Sending to one takes work an ordinary address does not. Apple requires that you "register your outbound emails or email domains and use Sender Policy Framework (SPF) to authenticate your outbound emails", and imposes a daily limit of 100 emails per relay address, counting user replies. A transactional mailer that was never configured for this fails silently from the user's point of view: the address is valid, your delivery succeeds, and nothing arrives.
The stable-per-team property is what makes a relay address safe to treat as an identity at all, and it is why the address Apple gives you still works in the branch where an OAuth callback finds an existing account with that email. The verified flag carries the weight there, exactly as it does for any other provider.
Apple as a subclass of a provider registry, not a second code path
Apple is the provider that decides whether a codebase has a provider abstraction or a pile of conditionals, because it is the first one whose credentials are not a client id and a client secret. In this codebase it is a subclass of the generic entry, overriding the two hooks that deviate and nothing else:
Those four leaves do more than name credentials.
configured?iscredential_leaves.all? { |leaf, _| credential(leaf).present? }, so the Apple button appears only when all four values are filled, and the admin setup wizard generates one field per leaf with onlyprivate_keyflagged secret. Every value is read from encrypted per-environment credentials and never from ENV.One more entry has to agree, and it is easy to forget: the Content Security Policy.
form-actionis enforced on the redirect to the provider, sohttps://appleid.apple.comis allowlisted in the policy initializer as well as in the registry's own host map. Treating Apple as a subclass rather than a branch is the same instinct that keeps ten providers from becoming ten code paths, applied to the one provider that genuinely does not fit the shape.