ZevAuth
Sign up

Your backend

Rails

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

bundle add zevauth

Rails 7.0 or later. The integration is part of the Ruby gem and loads itself when your app has Rails.

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:

# 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:

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

Protecting controllers

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

With options:

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:

Statuserror.codeWhen
401unauthorizedNobody is signed in
403session_task_pendingThe session has a task to finish. The body adds pendingTasks
403mfa_requiredrequire_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:

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:

<% 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. 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 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:

<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.

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

# 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

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.

Updated at, Sunday, November 1, 2026