or continue with
<% providers.each do |provider| %>
<%= link_to "Sign in with #{provider.to_s.capitalize}",
oauth_authorize_path(provider: provider),
class: "btn btn--oauth btn--#{provider}",
data: { turbo_method: :get } %>
<% end %>
<% end %>
```
### Disabling a single provider temporarily [#disabling-a-single-provider-temporarily]
Remove the entry from `oauth_providers` and restart the Rails server. No template change needed.
### Customising the button copy or markup [#customising-the-button-copy-or-markup]
Override the partial at `app/views/supabase/rails/oauth/_buttons.html.erb`. The contract worth preserving:
* `oauth_authorize_path(provider: provider)` resolves to `GET /oauth/:provider/authorize` — the route the `OauthController#authorize` action handles. Change the helper and you break the OAuth start leg.
* `data: { turbo_method: :get }` — the default link uses Turbo. If your host disables Turbo, drop the `data` hash.
* The loop variable `provider` is a Symbol or String exactly as it appears in `oauth_providers`. Use `to_s` before string interpolation.
### Customising per-provider styling [#customising-per-provider-styling]
Use the loop variable to vary classes / icons per provider:
```erb
<% Array(Rails.application.config.supabase.oauth_providers).each do |provider| %>
<%= link_to oauth_authorize_path(provider: provider),
class: "btn btn--oauth btn--#{provider}",
data: { turbo_method: :get } do %>
<%= image_tag "oauth/#{provider}.svg", alt: "", class: "icon" %>
Sign in with <%= provider.to_s.capitalize %>
<% end %>
<% end %>
```
The implementation behind each button — PKCE state generation, signed-cookie verifier storage, callback exchange — lives in [`OauthController`](/reference/rails/controllers/oauth) and [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage). The view layer's only job is the link itself.
## Worked example: adding a logo and restyling with Tailwind [#worked-example-adding-a-logo-and-restyling-with-tailwind]
End-to-end: generate the templates, drop a logo at the top, restyle the sign-in form with Tailwind utilities, and update the shared partials once so the rest of the views inherit the look.
### Step 1 — Copy the templates into your app [#step-1--copy-the-templates-into-your-app]
```bash
bin/rails generate supabase:views
```
All eight files land under `app/views/supabase/rails/`. See [`supabase:views`](/reference/rails/generators/views) for re-run semantics, the `--skip` upgrade workflow, and the manual rollback steps.
### Step 2 — Add a logo to `sessions/new.html.erb` [#step-2--add-a-logo-to-sessionsnewhtmlerb]
Replace the gem default with a layout-wrapping version that puts a logo at the top, restyles the form with Tailwind classes, and keeps the four-piece controller contract intact:
```erb
<%# app/views/supabase/rails/sessions/new.html.erb %>
<%= image_tag "logo.svg", alt: "Acme", class: "mx-auto mb-8 h-10 w-auto" %>
Sign in to your account
<%= render "supabase/rails/shared/flash" %>
<%= form_with url: session_path, class: "mt-8 space-y-4" do |form| %>
<%= form.label :email, class: "block text-sm font-medium text-gray-700" %>
<%= form.email_field :email,
required: true,
autofocus: true,
autocomplete: "username",
value: params[:email],
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
<%= form.password_field :password,
required: true,
autocomplete: "current-password",
maxlength: 72,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
<%= form.submit "Sign in",
class: "w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" %>
<% end %>
<%= link_to "Forgot password?", new_password_path, class: "text-indigo-600 hover:text-indigo-500" %>
<%= link_to "Create an account", new_registration_path, class: "text-indigo-600 hover:text-indigo-500" %>
<%= render "supabase/rails/oauth/buttons" %>
```
The contract preserved (verify against the [sessions/new contract](#sessions-new-html-erb)):
* `form_with url: session_path` — still posts to `POST /session`.
* `:email` and `:password` field names — unchanged, controller reads them as-is.
* `maxlength: 72` — preserved on the password field.
* `render "supabase/rails/shared/flash"` — flash partial still rendered, so bad-credential errors still surface.
### Step 3 — Restyle `shared/_flash.html.erb` once for every form [#step-3--restyle-shared_flashhtmlerb-once-for-every-form]
This is the highest-leverage edit in the section — every form renders this partial, so a single Tailwind rewrite styles flash messages everywhere:
```erb
<%# app/views/supabase/rails/shared/_flash.html.erb %>
<% if flash[:alert] %>
<% providers.each do |provider| %>
<%= link_to oauth_authorize_path(provider: provider),
class: "inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50",
data: { turbo_method: :get } do %>
<%= image_tag "oauth/#{provider}.svg", alt: "", class: "mr-2 h-5 w-5" %>
Sign in with <%= provider.to_s.capitalize %>
<% end %>
<% end %>
<% end %>
```
The contract preserved (verify against the [`oauth/_buttons` contract](#oauth-_buttons-html-erb)):
* `oauth_authorize_path(provider: provider)` — same URL helper, OAuth start leg still hits the gem's controller.
* `data: { turbo_method: :get }` — Turbo behaviour preserved.
* `Array(...)` guard — still no-ops when `oauth_providers` is empty.
### Step 5 — Apply the same shape to the remaining forms [#step-5--apply-the-same-shape-to-the-remaining-forms]
`registrations/new`, `passwords/new`, `passwords/edit`, `otp/new`, and `otp/verify` all follow the same skeleton as `sessions/new`: a heading, the flash partial, a `form_with` with its specific URL helper and field whitelist, and a back-link. Copy the layout wrapper + field grouping from Step 2 across each, preserving each template's per-form contract from the per-template sections above.
### Step 6 — Verify in the browser [#step-6--verify-in-the-browser]
```bash
bin/rails server
```
Hit `http://localhost:3000/session/new`, then `/registration/new`, `/passwords/new`, `/otp/new`. The logo and Tailwind styles should render across all of them. To prove the override is active (not the gem still serving its own copy), temporarily rename `app/views/supabase/rails/sessions/new.html.erb` to `.bak` — the page should fall back to the gem's bare default on the next reload.
## See also [#see-also]
* [`supabase:views`](/reference/rails/generators/views) — generator that drops these templates into your app, plus the full override-precedence mechanics, re-run semantics, and rollback steps.
* [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) — populate this to make the OAuth buttons partial render anything.
* [Controllers](/reference/rails/controllers) — the five base controllers that render these templates, including each action's params whitelist and outcome dispatch table.
* [Controllers → Sessions](/reference/rails/controllers/sessions) · [Registrations](/reference/rails/controllers/registrations) · [Passwords](/reference/rails/controllers/passwords) · [OTP](/reference/rails/controllers/otp) · [OAuth](/reference/rails/controllers/oauth) — per-controller pages with the full action bodies and override patterns.
* [Authentication](/reference/rails/authentication) — the `Authentication` concern and the helpers (`authenticated?`, `current_user`) available inside customised templates.
# AuthClientFactory (/reference/rails/web-mode/auth-client-factory)
`Supabase::Rails::Web::AuthClientFactory` is the single place every `:web`-mode component goes when it needs an `Supabase::Auth::Client` — sign-in, sign-up, OAuth start/callback, OTP request/verify, password reset, refresh. The factory builds one client per request, caches it in `request.env["supabase.rails.auth_client"]`, and wires it with the invariants the cookie-session model requires: `auto_refresh_token: false` (no background timers in workers), `flow_type: "pkce"` (required for OAuth), `storage:` set to a per-request [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage).
You almost never call `build` directly. The gem-shipped controllers and [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) call it internally; hosts writing custom OAuth or admin flows can call it from their own controller actions to reuse the same per-request client (and the same PKCE storage, which matters for OAuth). The module is documented here because (a) the four constructor invariants are load-bearing for the cookie-session model and a hand-rolled `Supabase::Auth::Client.new` will almost certainly violate one of them, (b) the env-key cache is the only mechanism that keeps `RequestScopedStorage` shared within a request, and (c) the publishable-key resolution path is the one place the `keys["default"]` requirement surfaces as an `EnvError`.
```ruby
# In a custom controller — get the per-request auth client.
client = Supabase::Rails::Web::AuthClientFactory.build(request)
client.sign_up(email: "alice@example.com", password: "...")
```
## `AuthClientFactory.build(request, env: nil, supabase_options: nil)` [#authclientfactorybuildrequest-env-nil-supabase_options-nil]
| Returns | Raises |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `Supabase::Auth::Client` — the same instance for the duration of the request | `Supabase::Rails::EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY)` if no `default` publishable key is resolvable |
| Argument | Type | Default | Notes |
| ------------------- | -------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request` | `ActionDispatch::Request` (or any duck-type with `#env` and `#cookie_jar`) | required | The request the client should be scoped to. `RequestScopedStorage` reads `request.env` for memoization and `request.cookie_jar` for PKCE-verifier fallback. |
| `env:` | `SupabaseEnv` \| `Hash` \| `nil` | `nil` | Passed to `Supabase::Rails::Env.resolve`. When already a `SupabaseEnv`, it is used as-is. |
| `supabase_options:` | `Hash` \| `nil` | `nil` | Extra options merged into the client. Only `:global => { :headers => Hash }` is honoured today; future keys are silently passed through. |
### Caching [#caching]
The factory caches the constructed client in `request.env[ENV_KEY]` where `ENV_KEY = "supabase.rails.auth_client"`. The second call within a request returns the cached instance:
```ruby
def build(request, env: nil, supabase_options: nil)
cached = request.env[ENV_KEY]
return cached unless cached.nil?
request.env[ENV_KEY] = build_client(request, env: env, supabase_options: supabase_options)
end
```
Two consequences worth knowing:
* **The `env:` / `supabase_options:` arguments to the second call are ignored.** Whoever called `build` first locked in the client's configuration for the rest of the request. A controller that wants to override env/options has to call `build` *before* anything else in the pipeline does — or directly construct its own `Supabase::Auth::Client` and skip the factory. In practice the middleware always calls `build` first via `CookieCredentialStrategy`, so subsequent controller calls inherit those options.
* **The client's `RequestScopedStorage` is shared across callers within a request.** This is the load-bearing property: when `OauthController#callback` calls `client.exchange_code_for_session`, the PKCE verifier written by `OauthController#create` (in a *previous* request) is read from the same storage instance the cookie fallback was bound to.
The cache is per-request: when the request finishes and the Rack env is discarded, the client and its storage are eligible for GC. There is no module-level state.
## Invariants [#invariants]
The factory hard-codes four `Supabase::Auth::Client.new` options. They are documented as code constants only inline — the factory is the contract.
| Option | Value | Why |
| -------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto_refresh_token` | `false` | `supabase-rb` would otherwise spawn a background `Timer` thread per session that outlives the request, leaking across Puma worker fork cycles. Refresh is performed *inline* by [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy). This is enforced repo-wide by `spec/supabase/rails/auto_refresh_token_invariant_spec.rb` — see [Codebase patterns](/reference/rails). |
| `flow_type` | `"pkce"` | Required for the OAuth round-trip. Without PKCE, `sign_in_with_oauth` falls back to the implicit flow, which doesn't work with the cookie-session model. |
| `persist_session` | `true` | Tells the upstream client to call `storage.set_item` on the session. Because `storage` is request-scoped, "persist" means "within this request" — the encrypted `sb-session` cookie is what carries the session across requests, written by [`SessionStore`](/reference/rails/authentication/session-store) from `start_new_session_for`. |
| `storage` | `RequestScopedStorage.new(request)` | Per-request `Supabase::Auth::SupportedStorage` implementation. See [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) for the PKCE-verifier cookie fallback. |