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

```bash
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](/sdk/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:

```dotenv
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:

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

## Who is signed in

```ruby
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

```ruby
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:

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

```ruby
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

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

```ruby
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

```ruby
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](/concepts/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:

```html
<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>
```

> **Note:**
> Your server 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, and only access
> tokens reach your server.

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

```ruby
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

```ruby
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](/api/users/).

Every user, page after page, as an Enumerator:

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

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

## Webhooks

```ruby
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](/concepts/server-sdks/) explains what that guarantees.

Source and issues: [github.com/zevop/zevauth-ruby](https://github.com/zevop/zevauth-ruby).
