hotwire-native-auth

star 1

Implements web-based authentication for Hotwire Native iOS and Android apps. Use when adding authentication to a Hotwire Native app, integrating sign in/sign out with native tab bars, setting up bridge components for auth state, or when user mentions Hotwire Native authentication, native app auth, or mobile auth with Rails.

LaunchPadLab By LaunchPadLab schedule Updated 4/27/2026

name: "hotwire-native-auth" description: "Implements web-based authentication for Hotwire Native iOS and Android apps. Use when adding authentication to a Hotwire Native app, integrating sign in/sign out with native tab bars, setting up bridge components for auth state, or when user mentions Hotwire Native authentication, native app auth, or mobile auth with Rails."

Hotwire Native: Web-Based Authentication

Overview

Web-based authentication reuses your existing Rails authentication screens and logic for Hotwire Native iOS and Android apps. Instead of building native login UI with SwiftUI/Jetpack Compose and JSON API endpoints, the native app renders the same /session/new HTML form and submits it via the web view.

Benefits over native authentication:

  • Zero additional native UI code for login screens
  • Zero additional API endpoints
  • Reuses existing server-side authentication logic
  • Single source of truth: the Rails server

Architecture

┌─────────────────────────────────────────────────────┐
│                    Rails Server                      │
│  ┌─────────────┐  ┌──────────┐  ┌────────────────┐ │
│  │ Auth Concern │  │ Sessions │  │ Layout <meta>  │ │
│  │ (permanent   │  │Controller│  │ data-bridge-   │ │
│  │  cookie)     │  │          │  │ authenticated  │ │
│  └─────────────┘  └──────────┘  └───────┬────────┘ │
└─────────────────────────────────────────┼───────────┘
                                          │
              ┌───────────────────────────┼──────┐
              │  JS Bridge Component      │      │
              │  (Stimulus controller)    ▼      │
              │  sends signIn/signOut message    │
              └──────────┬──────────────────────┘
                         │
            ┌────────────┼────────────┐
            ▼                         ▼
   ┌─────────────────┐    ┌─────────────────────┐
   │  iOS Bridge      │    │  Android Bridge      │
   │  Component       │    │  Component           │
   │  → SceneDelegate │    │  → MainActivity      │
   │  → Toggle tabs   │    │  → Toggle bottom nav │
   └─────────────────┘    └─────────────────────┘

Implementation Steps

Step 1: Generate a Long-Lived Cookie

Use cookies.signed.permanent so the session persists even after the native app is closed.

Rolling your own auth (or Rails 8 authentication generator):

Keep this cookie shape aligned with the web authentication concern and Action Cable connection. This skill standardizes on a signed, permanent session_token cookie whose value is session.token.

# app/controllers/concerns/authentication.rb
def start_new_session_for(user)
  session = user.sessions.create!(
    ip_address: request.remote_ip,
    user_agent: request.user_agent
  )
  Current.session = session
  cookies.signed.permanent[:session_token] = {
    value: session.token, httponly: true, same_site: :lax
  }
end

If using Devise, always remember the user on native:

<%= form_with url: session_path do |form| %>
  <% if hotwire_native_app? %>
    <%= form.hidden_field :remember_me, value: true %>
  <% else %>
    <%= form.check_box :remember_me %>
    <%= form.label :remember_me %>
  <% end %>
<% end %>

Step 2: Surface the Authentication Status

Add a <meta> tag in your application layout that exposes auth state on every page:

<%# app/views/layouts/application.html.erb %>
<html>
  <body>
    <meta data-controller="bridge--authentication"
      data-bridge-authenticated="<%= authenticated? %>">
    <%# ... %>
  </body>
</html>

Create the bridge Stimulus controller:

bin/rails generate stimulus bridge/authentication
// app/javascript/controllers/bridge/authentication_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "authentication"

  connect() {
    const authenticated = this.bridgeElement
      .bridgeAttribute("authenticated") === "true"

    if (authenticated) {
      this.send("signIn")
    } else {
      this.send("signOut")
    }
  }
}

Gotcha: bridgeAttribute() returns a string, not a boolean. Both "true" and "false" are truthy in JavaScript. You MUST compare with === "true".

Pin the bridge package in your importmap:

# config/importmap.rb
pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js"

Step 3: Create Native Bridge Components

iOS Bridge Component

// App/Components/AuthenticationComponent.swift
import HotwireNative

class AuthenticationComponent: BridgeComponent {
    override nonisolated class var name: String { "authentication" }

    override func onReceive(message: Message) {
        switch message.event {
        case "signIn": sceneDelegate?.signIn()
        case "signOut": sceneDelegate?.signOut()
        default: print("AuthenticationComponent", "Unknown event: \(message)")
        }
    }

    private var viewController: UIViewController? {
        delegate?.destination as? UIViewController
    }

    private var sceneDelegate: SceneDelegate? {
        viewController?.view.window?
            .windowScene?.delegate as? SceneDelegate
    }
}

Register in AppDelegate:

Hotwire.registerBridgeComponents([
    AuthenticationComponent.self
])

Android Bridge Component

// AuthenticationComponent.kt
class AuthenticationComponent(
    name: String,
    private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {
    private val fragment: Fragment
        get() = delegate.destination.fragment

    private val mainActivity: MainActivity
        get() = fragment.activity as MainActivity

    override fun onReceive(message: Message) {
        when (message.event) {
            "signIn" -> mainActivity.signIn()
            "signOut" -> mainActivity.signOut()
        }
    }
}

Register in your Application subclass:

Hotwire.registerBridgeComponents(
    BridgeComponentFactory("authentication", ::AuthenticationComponent)
)

Step 4: Toggle the Tab Bar

iOS SceneDelegate

func signIn() {
    if tabBarController.viewControllers?.count != Tab.all.count {
        tabBarController.setTabBarHidden(false, animated: true)
        tabBarController.load(Tab.all)
    }
}

func signOut() {
    if tabBarController.viewControllers?.count != 1 {
        tabBarController.setTabBarHidden(true, animated: true)
        tabBarController.load(Tab.unauthenticated)
    }
}

Android MainActivity

fun signIn() {
    if (bottomNavigationController?.tabs?.size != tabs.size) {
        bottomNavigationController?.visibility = Visibility.DEFAULT
        bottomNavigationController?.load(tabs)
        delegate.resetNavigators()
    }
}

fun signOut() {
    if (bottomNavigationController?.tabs?.size != unauthenticatedTabs.size) {
        bottomNavigationController?.visibility = Visibility.HIDDEN
        bottomNavigationController?.load(unauthenticatedTabs)
        delegate.resetNavigators()
    }
}

Gotcha - Infinite loop: The bridge component fires on every page load. Without the idempotency guard (if ... count != ...), signing in calls signIn() for each tab, which reloads tabs, which triggers signIn() again - infinite loop.

Android-specific: You must call delegate.resetNavigators() after load() because on Android, load() only sets up UI and callbacks - it doesn't trigger network requests.

Supporting Rails Setup

Native-Only CSS

Conditionally load a stylesheet that hides web-only elements from native apps:

<%# app/views/layouts/application.html.erb %>
<% if hotwire_native_app? %>
  <%= stylesheet_link_tag "native" %>
<% end %>
/* app/assets/stylesheets/native.css */
.d-hotwire-native-none {
  display: none;
}

Apply to elements like page headers that are redundant in native apps:

<header class="d-hotwire-native-none">
  <%= link_to "Home", root_path %>
</header>

Path Configuration Controller

Serve path configuration JSON so native apps know which URLs should be modals:

# app/controllers/configurations_controller.rb
class ConfigurationsController < ApplicationController
  allow_unauthenticated_access

  def ios
    render json: {
      settings: {},
      rules: [
        { patterns: ["/new$", "/edit$"], properties: { context: "modal" } },
        { patterns: ["/session/new"], properties: { context: "default" } }
      ]
    }
  end

  def android
    render json: {
      settings: {},
      rules: [
        { patterns: [".*"], properties: { context: "default", pull_to_refresh_enabled: true } },
        { patterns: ["/new$", "/edit$"], properties: { context: "modal" } },
        { patterns: ["/session/new"], properties: { context: "default" } }
      ]
    }
  end
end
# config/routes.rb
resource :configuration, only: [], constraints: { format: :json } do
  get :ios, on: :member
  get :android, on: :member
end

Important: /session/new must be context: "default" (not modal) so the sign-in screen displays as a full page, and place it after the modal rules so it takes precedence.

Sign-In View for Native

Hide the page header on native since navigation is handled natively:

<%# app/views/sessions/new.html.erb %>
<header class="d-hotwire-native-none">
  <h1>Sign In</h1>
</header>

<%= form_with url: session_path, data: { turbo_action: "replace" } do |form| %>
  <%= form.email_field :email_address %>
  <%= form.password_field :password %>
  <%= form.submit "Sign in" %>
<% end %>

Use data: { turbo_action: "replace" } on the form so Turbo replaces the page instead of pushing to the navigation stack after sign-in.

Workflow Checklist

Hotwire Native Auth Implementation:

Rails:
- [ ] Authentication uses cookies.signed.permanent (not session-only cookies)
- [ ] <meta> tag with data-bridge-authenticated in application layout
- [ ] Bridge Stimulus controller at bridge/authentication_controller.js
- [ ] @hotwired/hotwire-native-bridge pinned in importmap
- [ ] native.css loaded conditionally for hotwire_native_app?
- [ ] .d-hotwire-native-none hides web headers in native
- [ ] Path configuration endpoints (iOS + Android)
- [ ] /session/new set to default context (not modal)
- [ ] Sign-in form uses data-turbo-action="replace"

iOS:
- [ ] AuthenticationComponent registered in AppDelegate
- [ ] SceneDelegate has signIn()/signOut() with idempotency guards
- [ ] Tab definitions for authenticated and unauthenticated states
- [ ] Path configuration loaded from server
- [ ] App starts in unauthenticated state

Android:
- [ ] AuthenticationComponent registered in Application subclass
- [ ] MainActivity has signIn()/signOut() with idempotency guards
- [ ] delegate.resetNavigators() called after loading tabs
- [ ] Tab definitions for authenticated and unauthenticated states
- [ ] activity_main.xml has FragmentContainerViews + BottomNavigationView
- [ ] Path configuration loaded from server
- [ ] App starts in unauthenticated state

Common Gotchas

Issue Cause Fix
Bridge never fires Missing @hotwired/hotwire-native-bridge pin Add to importmap.rb
Auth always truthy in JS bridgeAttribute() returns string Compare with === "true"
Infinite loop on sign in signIn() reloads tabs, triggers bridge again Add idempotency check on tab count
Tab bar flashes on cold start App starts with all tabs visible Start in unauthenticated state
Android tabs don't load content load() doesn't trigger requests Call delegate.resetNavigators()
Sign-in opens as modal Path config matches /new$ Add /session/new rule with context: "default" after modal rules
Native app loses session Cookie not permanent Use cookies.signed.permanent

References

Install via CLI
npx skills add https://github.com/LaunchPadLab/ai-bank --skill hotwire-native-auth
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
LaunchPadLab
LaunchPadLab Explore all skills →