LaunchKit

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:

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  apple = Rails.application.credentials.apple

  provider :apple, apple.client_id, "", {
    scope: "email name",
    team_id: apple.team_id,
    key_id: apple.key_id,
    pem: apple.private_key
  }
end

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:

def client_secret
  jwt = JSON::JWT.new(iss: options.team_id, aud: ISSUER, sub: client_id,
                      iat: Time.now, exp: Time.now + 60)
  jwt.kid = options.key_id
  jwt.sign(private_key).to_s
end

def private_key
  ::OpenSSL::PKey::EC.new(options.pem)
end

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:

get "/auth/:provider/callback", to: "sessions/omniauth#create"

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:

match "/auth/:provider/callback", to: "sessions/omniauth#create", via: %i[get post]

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:

if !options.provider_ignores_state &&
   (request.params["state"].to_s.empty? || request.params["state"] != session.delete("omniauth.state"))
  fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
end

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:

class Apple < Auth::OauthProvider
  def credential_leaves
    [ [ "client_id", false ], [ "team_id", false ], [ "key_id", false ], [ "private_key", true ] ]
  end

  def omniauth_args
    [ strategy, credential("client_id"), "", {
      scope: scope, team_id: credential("team_id"),
      key_id: credential("key_id"), pem: credential("private_key")
    } ]
  end
end

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.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles