---
title: Rails
description: Protect controllers, read the signed-in user in views, serve server-rendered sessions and receive webhooks in Rails.
---

```bash
bundle add zevauth
```

Rails 7.0 or later. The integration is part of the [Ruby gem](/sdk/ruby/)
and loads itself when your app has Rails.

```dotenv
ZEVAUTH_ENVIRONMENT_ID=env_live_…
ZEVAUTH_PUBLISHABLE_KEY=pk_live_…
ZEVAUTH_SECRET_KEY=sk_live_…
ZEVAUTH_WEBHOOK_SECRET=whsec_…
```

Prefer credentials? Set them in an initializer instead:

```ruby
# config/initializers/zevauth.rb
ZevAuth.configure do |c|
  c.secret_key     = Rails.application.credentials.dig(:zevauth, :secret_key)
  c.webhook_secret = Rails.application.credentials.dig(:zevauth, :webhook_secret)
end
```

Then mount the engine, which serves the session bridge and the handshake
page:

```ruby
# config/routes.rb
mount ZevAuth::Engine => "/zevauth"
```

## Protecting controllers

```ruby
class ApplicationController < ActionController::Base
  before_action :require_zevauth!
end
```

With options:

```ruby
class BillingController < ApplicationController
  require_zevauth only: %i[update destroy], require_mfa: true
end

class OnboardingController < ApplicationController
  # The one place a session that still has to set up two-step may reach.
  skip_before_action :require_zevauth!
  require_zevauth allow_pending_tasks: true
end
```

A refused request gets the same JSON every ZevAuth SDK sends:

| Status | `error.code` | When |
| --- | --- | --- |
| 401 | `unauthorized` | Nobody is signed in |
| 403 | `session_task_pending` | The session has a task to finish. The body adds `pendingTasks` |
| 403 | `mfa_required` | `require_mfa: true` and the session didn't pass a second factor |

The handlers are ordinary Rails ones, so you can replace the JSON with a
redirect to your sign-in page:

```ruby
class ApplicationController < ActionController::Base
  rescue_from ZevAuth::AuthRefused do |error|
    if request.format.html? && error.status == 401
      redirect_to sign_in_path
    else
      render json: error.body, status: error.status
    end
  end
end
```

## Who is signed in

In controllers and views:

```erb
<% if zevauth_signed_in? %>
  Signed in as <%= zevauth_user_id %>
  <% if zevauth_auth.has?(permission: "org:members:manage") %>
    <%= link_to "Manage members", members_path %>
  <% end %>
<% end %>
```

`zevauth_auth` is the full auth object: `user_id`, `organization_id`,
`organization_role`, `has?`, `mfa_verified?`, `pending_tasks` and `claims`.
See [Ruby](/sdk/ruby/#who-is-signed-in). It never raises: a lapsed session
reads as signed out.

The helpers are in every controller, including `ActionController::API` ones;
nothing is enforced until a controller asks.

## Server-rendered pages

Rails renders pages on the server, so the browser doesn't send a token in a
header. The [session bridge](/concepts/session-bridge/) keeps the current
access token in your own HttpOnly cookie instead, and the engine serves both
parts of it:

- `POST /zevauth/session`, where the browser SDK posts tokens;
- `GET /zevauth/handshake`, which a visitor passes through when their cookie
  lapsed.

Tell the browser SDK where to post, in your layout:

```erb
<script src="https://cdn.jsdelivr.net/npm/@zevauth/ui@0/dist/zevauth-ui.global.js"></script>
<script>
  ZevAuthUI.init({
    publishableKey: "<%= ENV.fetch('ZEVAUTH_PUBLISHABLE_KEY') %>",
    sessionBridge: { url: "/zevauth/session" },
  });
</script>
```

`require_zevauth!` handles a lapsed cookie for you: a page request is sent
through the handshake and comes straight back signed in, and an API request
gets 401 `session_refresh_required` so your front end can send its token in
the `Authorization` header instead.

> **Note:**
> Rails never refreshes a session. Refresh tokens rotate on every use, and one
> used twice ends the session, so a server refreshing alongside the browser
> would sign people out. Only the browser refreshes.

Mounted somewhere other than `/zevauth`? Set `c.bridge_path` to the session
URL and `c.bridge = ZevAuth::Bridge::Config.new(handshake_path: "...")` to
the handshake URL.

## Webhooks

```ruby
# config/routes.rb
post "/webhooks/zevauth", to: "zevauth_webhooks#create"

# app/controllers/zevauth_webhooks_controller.rb
class ZevauthWebhooksController < ActionController::API
  def create
    event = zevauth_webhook_event

    case event.type
    when "user.created" then WelcomeMailer.with(user_id: event.data["id"]).deliver_later
    end

    head :ok
  rescue ZevAuth::WebhookError
    head :bad_request
  end
end
```

`zevauth_webhook_event` verifies `request.raw_post`, the body exactly as
received. Never verify `params`: Rails has already parsed it, and a
re-encoded body fails the signature check, correctly.

An `ActionController::API` controller has no CSRF protection to skip. If
yours inherits from `ActionController::Base`, add
`skip_forgery_protection only: :create`.

## Backend API

```ruby
ZevAuth.client.users.get(zevauth_user_id)
ZevAuth.client.organizations.add_member("org_123", user_id: "user_456", role: "org:admin")
```

The full client is on the [Ruby page](/sdk/ruby/#backend-api).
