ZevAuth
Sign up

Your backend

Ruby

Verify sessions, manage users and organizations, receive webhooks and serve server-rendered pages from any Ruby app, including Sinatra, Roda and Hanami.

bundle add zevauth

Ruby 3.1 or later. The gem has one dependency (jwt, to read the signing keys) and uses Rack when your app has it. Using Rails? Read Rails instead. It builds on everything here and wires it into controllers, routes and views for you.

Set your keys as environment variables and there is nothing else to configure:

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

Or set them in code, which wins over the environment:

ZevAuth.configure do |c|
  c.environment_id = "env_live_…"
  c.secret_key     = Rails.application.credentials.zevauth_secret_key
end

Who is signed in

auth = ZevAuth.get_auth(request)    # a Rack env or any request object

auth.signed_in?                     # false when nobody is
auth.user_id                        # "user_…"
auth.session_id
auth.organization_id                # the active organization, or nil
auth.organization_role              # "org:admin"
auth.has?(permission: "org:members:manage")
auth.mfa_verified?                  # passed a second factor at sign-in
auth.pending_tasks                  # ["setup_mfa"] until it's done
auth.claims                         # every verified claim

get_auth reads the Authorization: Bearer header, then the session-bridge cookie for page requests. It never raises for a missing, expired or tampered token: each of those simply means nobody is signed in, so a page deciding what to render can’t crash because a session lapsed.

Tokens are verified locally against your environment’s published signing keys. The keys are fetched once and cached for ten minutes, so checking a request costs no network call.

Refusing a request

ZevAuth.require_auth(request)                        # signed in
ZevAuth.require_auth(request, require_mfa: true)     # and passed two-step

require_auth raises ZevAuth::AuthRefused, which carries the response to send:

Statuserror.codeWhen
401unauthorizedNobody is signed in
403session_task_pendingThe session has a task to finish, such as setting up two-step verification. The body adds pendingTasks
403mfa_requiredrequire_mfa: true and the session didn’t pass a second factor
rescue ZevAuth::AuthRefused => e
  e.status       # 401
  e.code         # "unauthorized"
  e.to_rack      # [401, {...}, ['{"error":{"code":"unauthorized",...}}']]

Pass allow_pending_tasks: true only on the routes that help finish the task.

Rack apps: Sinatra, Roda, Hanami

# config.ru
use ZevAuth::Rack::Middleware

map "/api" do
  use ZevAuth::Rack::RequireAuth, require_mfa: true
  run Api
end

run App

The middleware puts the auth on every request at env["zevauth.auth"]. It never refuses anything; ZevAuth::Rack::RequireAuth does, with the JSON responses above. In a Sinatra route:

get "/account" do
  refused = ZevAuth::Rack.refusal(env, require_mfa: true)
  halt(*refused) if refused
  "Hello #{env['zevauth.auth'].user_id}"
end

Verifying a token yourself

claims = ZevAuth.verify_token(token)   # raises ZevAuth::VerifyError

VerifyError#code is one of malformed_token, unsupported_algorithm, unknown_key, invalid_signature, invalid_issuer, invalid_audience, token_expired, token_not_yet_valid and jwks_unavailable: the same codes as every ZevAuth server SDK. Only RS256 is accepted; a token can never choose its own algorithm.

Server-rendered pages

In an app whose pages are rendered on the server, the browser asks for pages itself and sends no Authorization header. The session bridge fixes that: the browser SDK hands each new access token to your server, which keeps it in its own HttpOnly cookie.

ZevAuth::Rack::Middleware serves both parts for you:

  • POST /zevauth/session, where the browser SDK posts tokens;
  • GET /zevauth/handshake, the page a visitor passes through when their cookie lapsed, so the browser can refresh the session and send them straight back.

Then tell the browser SDK where to post:

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

Cookies are __Host- cookies by default. To share them across subdomains, or for plain-http local development:

ZevAuth.configure do |c|
  c.bridge = ZevAuth::Bridge::Config.new(
    cookie_domain: ".example.com",
    allowed_origins: ["https://app.example.com", "https://www.example.com"],
  )
  # c.bridge = ZevAuth::Bridge::Config.new(secure: false)   # http://localhost only
end

Without the middleware, mount the two Rack apps yourself: ZevAuth::Bridge::Endpoint.new and ZevAuth::Bridge::HandshakePage.new.

Backend API

client = ZevAuth.client                     # uses ZEVAUTH_SECRET_KEY

client.users.get("user_123")
client.users.create(email: "ada@example.com", first_name: "Ada")
client.users.update("user_123", public_metadata: { plan: "pro" })
client.users.set_blocked("user_123", true)
client.users.revoke_sessions("user_123")
client.users.reset_mfa("user_123", revoke_sessions: true)

client.organizations.create(name: "Acme", created_by: "user_123")
client.organizations.add_member("org_123", user_id: "user_456", role: "org:admin")

Keyword arguments use Ruby names and are sent as the API’s own (first_name: becomes firstName). Responses are Hashes with the API’s field names, exactly as in the API reference.

Every user, page after page, as an Enumerator:

client.users.each(metadata: { plan: "pro" }) { |user| puts user["email"] }
client.users.each.lazy.select { |u| u["emailVerified"] }.first(10)

list returns one page (data, has_more?, next_cursor, total_count) when you’d rather page yourself.

Errors and retries

Failures raise ZevAuth::APIError with code, status, message and request_id. Quote the request_id when you contact ZevAuth support.

Rate limits (429) and brief outages (502, 503, 504, or no answer) are retried twice, honouring Retry-After. A 500 is never retried: the request may already have run, and retrying is how one create becomes two.

ZevAuth::Client.new(secret_key: "sk_live_…", timeout: 10, max_retries: 4)

Webhooks

event = ZevAuth::Webhooks.construct_event(
  payload: env["rack.input"].read,                 # the raw body
  signature: env["HTTP_ZEVAUTH_SIGNATURE"],
)
event.type   # "user.created"
event.data

construct_event raises ZevAuth::WebhookError with webhook_malformed_signature, webhook_timestamp_out_of_range or webhook_signature_mismatch.

Verify the raw body. The signature covers the exact bytes ZevAuth sent, so a body that was parsed and re-encoded fails, correctly. Deliveries can arrive more than once: use the zevauth-delivery-id header or the event id to handle each one once.

The same in every language

This gem implements the same contract as the ZevAuth libraries for Node.js, PHP, Python, Go and Java, and passes the same conformance tests. Server SDKs explains what that guarantees.

Source and issues: github.com/zevop/zevauth-ruby.

Updated at, Sunday, November 1, 2026