Sign in with Apple in Rails: the provider with no client secret
September 12, 2026
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 alg ES256 and kid, a
10 character key identifier; the payload carries iss, the 10 character Team ID that issued the
secret, iat, exp, aud set to https://appleid.apple.com, and sub, 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 .p8 private 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 the
client_id for 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 .p8 file is the PEM-encoded elliptic curve private key generated
alongside the Key ID.
The .p8 matters 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::Apple declares
option :name, 'apple', so the request phase is /auth/apple and 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 itself google_oauth2 and
Microsoft's calls itself
microsoft_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-apple looks 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::OAuth2 declares
args %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, because
omniauth-apple defines client_secret as 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 pem option, 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, fragment and form_post as the valid values of response_mode and 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 the oauth2 gem'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", plus id_token, state, and on one occasion user. The failure body carries
error, 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, and
callback_phase then does env['omniauth.auth'] = auth_hash followed by call_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.1
carries action_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 skip verify_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, and
omniauth-rails_csrf_protection stops 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-oauth2 writes 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 = :lax in 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 as csrf_detected before
the token is ever exchanged. The same cookie holds the nonce: omniauth-apple writes
session['omniauth.nonce'] during the authorize step and reads it back with session.delete, which
it needs whenever the identity token declares nonce_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_state is a real
omniauth-oauth2 option, declared as option :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-apple take 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 a user form 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_name and last_name read
user_info.dig('name', ...), which parses request.params['user'], while email reads
id_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-apple decodes the token with signature
verification skipped, reads its kid, fetches Apple's JWK set from
https://appleid.apple.com/auth/keys for that key id, and only then verifies the signature against
it. A fetch failure raises jwks_fetching_failed and a bad signature raises
id_token_signature_invalid, so neither degrades into an accepted token.
Claims are checked after the signature: iss must equal https://appleid.apple.com, aud must be
the configured client_id or one of authorized_client_ids, iat must not be in the future, exp
must not be in the past, and the nonce is compared against the session value when, and only when,
the token carries nonce_supported. The uid OmniAuth reports is the token's sub claim.
Keying your identity records on that sub rather 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 their sub changing 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_email and email_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? is
credential_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 only
private_key flagged 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-action is
enforced on the redirect to the provider, so https://appleid.apple.com is 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.
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.