Implementing Apple Sign In with Devise in Rails 8

Why Apple Sign In?

If your iOS app offers any third-party sign-in (Google, Facebook, etc.), Apple requires you to also offer Sign in with Apple. Beyond the mandate, it's genuinely a good experience—users get one-tap authentication with Face ID and don't need to remember another password.

This guide walks through the full implementation in a production Rails 8 app using Devise and OmniAuth. We'll cover the web OAuth flow and a separate JWT-based flow for Hotwire Native iOS apps, where the native Sign in with Apple SDK handles the UI and sends a JWT to your server for validation.

1. Gems

Add these three gems to your Gemfile:

# Gemfile
gem "omniauth-apple"   # OmniAuth strategy for web-based Sign in with Apple
gem "apple_auth"       # JWT validation for native iOS Sign in with Apple
gem "jwt"              # Dependency for token handling

Run bundle install.

omniauth-apple handles the standard web OAuth redirect flow. apple_auth handles the native mobile flow where your iOS app sends a JWT token directly to your server for validation.

2. Apple Developer Portal Setup

Before writing any code, you need to configure a few things in the Apple Developer Portal:

  1. App ID — Enable "Sign in with Apple" capability on your app's identifier
  2. Service ID — Create a Service ID (this becomes your client_id for the web flow). Configure the return URL to https://yourdomain.com/users/auth/apple/callback
  3. Key — Create a private key with "Sign in with Apple" enabled. Download the .p8 file—you'll only get it once
  4. Note your Team ID (top-right of the portal) and Key ID (from the key you just created)

Store all of these in Rails credentials:

bin/rails credentials:edit
apple:
  client_id: "com.yourapp.service"   # Your Service ID
  team_id: "XXXXXXXXXX"              # Your Apple Team ID
  key_id: "YYYYYYYYYY"               # Your Key ID
  pem: |
    -----BEGIN PRIVATE KEY-----
    YOUR_P8_KEY_CONTENTS_HERE
    -----END PRIVATE KEY-----

3. Database Migration

Add provider and uid columns to your users table:

class AddOmniauthToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :provider, :string
    add_column :users, :uid, :string
    add_index :users, [ :provider, :uid ], unique: true
  end
end

Run bin/rails db:migrate.

4. Devise OmniAuth Configuration

In your Devise initializer, add the Apple OmniAuth provider:

# config/initializers/devise.rb
Devise.setup do |config|
  # ... existing config ...

  config.omniauth :apple,
    Rails.application.credentials.dig(:apple, :client_id),
    "",
    {
      scope: "email name",
      team_id: Rails.application.credentials.dig(:apple, :team_id),
      key_id: Rails.application.credentials.dig(:apple, :key_id),
      pem: Rails.application.credentials.dig(:apple, :pem)
    }
end

Note the empty string "" for the secret parameter—Apple uses a private key instead of a traditional OAuth secret.

5. User Model

The User model needs three things: the :omniauthable module, a from_omniauth class method, and a password_required? override.

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: [ :apple ]

  # OAuth user creation/lookup
  def self.from_omniauth(auth)
    # First try to find by provider and uid
    user = where(provider: auth.provider, uid: auth.uid).first

    # If not found, try to find by email (handles existing email-only accounts)
    user ||= where(email: auth.info.email).first

    if user
      # Update existing user with OAuth credentials if they don't have them
      if user.provider.blank? || user.uid.blank?
        user.update(provider: auth.provider, uid: auth.uid)
      end
      user
    else
      # Create a new user
      user = User.new(
        email: auth.info.email,
        password: Devise.friendly_token[0, 20],
        password_confirmation: Devise.friendly_token[0, 20],
        name: extract_name_from_auth(auth),
        provider: auth.provider,
        uid: auth.uid
      )
      user.save
      user
    end
  end

  # Extract name from Apple's auth payload
  def self.extract_name_from_auth(auth)
    case auth.provider
    when "apple"
      if auth.info.name.present?
        auth.info.name
      elsif auth.info.first_name.present? || auth.info.last_name.present?
        "#{auth.info.first_name} #{auth.info.last_name}".strip
      else
        # Apple doesn't always provide name after first authorization
        "Apple User"
      end
    else
      auth.info.name || "User"
    end
  end

  # Override password requirement for OAuth users
  def password_required?
    return false if provider.present? && uid.present?
    password.present? || password_confirmation.present?
  end
end

The from_omniauth method handles three scenarios:

  1. Returning OAuth user — found by provider + uid
  2. Existing email user — found by email, upgraded to OAuth (links accounts)
  3. New user — created with a random password they'll never need

6. OmniAuth Callbacks Controller

This controller handles the OAuth callback after Apple redirects back to your app:

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token
  skip_forgery_protection only: [ :apple ]

  def apple
    handle_omniauth_callback("Apple")
  end

  private

  def handle_omniauth_callback(provider_name)
    begin
      @user = User.from_omniauth(request.env["omniauth.auth"])
      if @user.persisted?
        sign_in_and_redirect @user, event: :authentication
        set_flash_message(:notice, :success, kind: provider_name) if is_navigational_format?
      else
        Rails.logger.warn("#{provider_name} OAuth sign up not completed: #{@user.errors.full_messages}")
        session["devise.#{provider_name.downcase}_data"] = request.env["omniauth.auth"].except(:extra)
        redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n").presence || "Sign Up not completed"
      end
    rescue => e
      Rails.logger.error("#{provider_name} OAuth error: #{e.message}")
      redirect_to new_user_session_path, alert: "Authentication failed. Please try again or use email sign in."
    end
  end

  def failure
    Rails.logger.error("Omniauth failure: #{failure_message}")
    redirect_to root_path, alert: "Authentication failed. #{failure_message}"
  end
end

Important: We skip CSRF verification because Apple sends the callback as a POST request, and the CSRF token won't survive the redirect through Apple's servers.

7. Routes

Wire up Devise with the custom callbacks controller:

# config/routes.rb
Rails.application.routes.draw do
  devise_for :users, controllers: {
    omniauth_callbacks: "users/omniauth_callbacks"
  }

  # Mobile Apple authentication (JWT validation)
  namespace :users do
    post "apple_auth", to: "apple_auth#authenticate_apple_user"
  end

  # API endpoints for native mobile apps
  namespace :api do
    post "apple_sign_in", to: "sessions#apple_sign_in"
  end
end

8. Sign-In Button

Create a shared partial that renders the correct button based on the client type:

<%# app/views/shared/_social_buttons.html.erb %>

<% apple_button_web_classes = "flex w-full items-center justify-center gap-3 rounded-md bg-black px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-gray-800 focus-visible:ring-transparent transition-colors duration-300" %>
<% apple_button_classes = "flex w-full items-center justify-center gap-3 rounded-md bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-gray-900 transition-colors duration-300" %>

<div class="mt-6 grid gap-4">
  <% if hotwire_native_app? && !hotwire_native_android_app? %>
    <!-- Native iOS: trigger bridge component -->
    <button
      data-controller="bridge--apple"
      data-action="bridge--apple#click"
      type="button"
      class="<%= apple_button_classes %>"
    >
      <%= render "shared/apple_svg" %>
      <span class="text-sm/6 font-semibold">Apple</span>
    </button>
  <% elsif !hotwire_native_android_app? %>
    <!-- Web: standard OmniAuth redirect -->
    <%= button_to user_apple_omniauth_authorize_path, method: :post, data: { turbo: false }, class: apple_button_web_classes do %>
      <%= render "shared/apple_svg" %>
      <span class="text-sm/6 font-semibold">Apple</span>
    <% end %>
  <% end %>
</div>

Key details:

  • data: { turbo: false } — disables Turbo for the form so the full redirect to Apple works
  • method: :post — OmniAuth requires POST for security (no GET requests)
  • The native iOS path uses a Stimulus bridge component instead of a redirect

9. Mobile Native Flow (Hotwire Native iOS)

When your app runs inside a Hotwire Native iOS shell, you don't want to redirect through Apple's web OAuth flow. Instead, the native app uses Apple's AuthenticationServices framework to show the native Sign in with Apple sheet, gets a JWT token, and sends it to your server for validation.

9a. JavaScript Bridge Component

The bridge component sends a message to the native iOS app:

// app/javascript/controllers/bridge/apple_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "apple-signin"

  connect() {
    super.connect()
  }

  click(event) {
    this.send("authenticate")
  }
}

When the user taps the Apple button in the web view, this sends an "authenticate" message to the native iOS side via the Hotwire Native bridge.

9b. AppleAuth Initializer

Configure the apple_auth gem for JWT validation:

# config/initializers/apple_auth.rb
AppleAuth.configure do |config|
  config.apple_client_id = Rails.application.credentials.dig(:apple, :client_id)
  config.apple_private_key = Rails.application.credentials.dig(:apple, :pem)
  config.apple_key_id = Rails.application.credentials.dig(:apple, :key_id)
  config.apple_team_id = Rails.application.credentials.dig(:apple, :team_id)
end

9c. API Sessions Controller

This controller validates the JWT from the native app and signs the user in:

# app/controllers/api/sessions_controller.rb
class Api::SessionsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def apple_sign_in
    payload = AppleAuth::UserIdentity.new(params[:user_id], params[:jwt_token]).validate!
    auth = build_auth_hash("apple", payload[:sub], {
      email: payload[:email] || params[:email],
      name: payload[:name] || full_name_from_params,
      first_name: payload[:given_name] || params[:first_name],
      last_name: payload[:family_name] || params[:last_name]
    })

    sign_in_with_auth_hash(auth)
  rescue => e
    render_error(e)
  end

  private

  def build_auth_hash(provider, uid, info)
    OmniAuth::AuthHash.new(provider: provider, uid: uid, info: info)
  end

  def full_name_from_params
    [ params[:first_name], params[:last_name] ].compact.join(" ").presence
  end

  def sign_in_with_auth_hash(auth)
    user = User.from_omniauth(auth)

    if user.persisted?
      sign_in(user)
      render json: {
        success: true,
        user_id: user.id,
        email: user.email,
        name: user.name
      }
    else
      render json: { success: false, errors: user.errors.full_messages },
             status: :unprocessable_entity
    end
  end

  def render_error(error)
    Rails.logger.error("[Auth Error] #{error.class}: #{error.message}")
    render json: { success: false, errors: [ error.message ] },
           status: :unauthorized
  end
end

The key insight: we reuse the same User.from_omniauth method by constructing an OmniAuth::AuthHash from the validated JWT payload. This means the user lookup/creation logic is identical for both the web and native flows.

10. Gotchas

Apple only sends the user's name on first authorization

This is the biggest surprise. Apple sends the user's name (first + last) only the very first time they authorize your app. On subsequent sign-ins, the name fields are nil. That's why extract_name_from_auth falls back to "Apple User"—if you miss capturing the name on first auth, it's gone. Make sure your from_omniauth method saves the name immediately on user creation.

Apple sends a POST callback

Unlike most OAuth providers that redirect with a GET, Apple sends a POST to your callback URL. This means:

  • You must skip_forgery_protection on the callback action
  • Your callback URL must accept POST requests

Private email relay

Users can choose "Hide My Email," which gives you a relay address like abc123@privaterelay.appleid.com. This is a real, working email address that forwards to the user. Treat it as their actual email—don't try to ask for their "real" email.

Account linking by email

Notice how from_omniauth looks up users by email as a fallback. This handles the case where a user first signs up with email/password, then later taps "Sign in with Apple" using the same email. Instead of creating a duplicate account, it links the Apple credentials to their existing account.

Two separate flows, one User model

The web flow (OmniAuth redirect) and the native flow (JWT validation) both end up calling User.from_omniauth with an OmniAuth::AuthHash. The native flow just constructs the hash manually from the validated JWT payload. This keeps the user creation/lookup logic DRY.

Disable Turbo on the sign-in button

The web sign-in button uses data: { turbo: false } because the OAuth flow involves a full-page redirect to Apple's servers. Turbo would try to fetch the redirect as a Turbo Stream and break.

Wrapping Up

The full implementation gives you two authentication paths that share the same user model logic:

  1. Web: User clicks button → OmniAuth redirects to Apple → Apple POSTs back → from_omniauth creates/finds user → signed in
  2. Native iOS: User taps button → bridge component triggers native Apple Sign In → iOS app sends JWT to /api/apple_sign_in → server validates JWT → from_omniauth creates/finds user → signed in

The pattern of building an OmniAuth::AuthHash from validated native credentials and feeding it into the same from_omniauth method is reusable for any provider—we use the identical approach for Google Sign In on iOS and Android.