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