ZevAuth
Sign up

Your backend

PHP

Verify sessions, manage users and organizations, receive webhooks and serve server-rendered pages from any PHP app, including WordPress.

composer require zevauth/zevauth-php guzzlehttp/guzzle

PHP 8.1 or later. The library works with any PSR-18 HTTP client; Guzzle is the usual one. Using Laravel? Read Laravel instead. It builds on everything here and wires it into middleware, guards and Blade for you.

Who is signed in

use ZevAuth\ZevAuth;

$zevauth = ZevAuth::create(
    environmentId: getenv('ZEVAUTH_ENVIRONMENT_ID'),     // env_live_…
    publishableKey: getenv('ZEVAUTH_PUBLISHABLE_KEY'),   // pk_live_…
);

$auth = $zevauth->getAuthFromGlobals();   // plain PHP: reads $_SERVER and $_COOKIE
$auth = $zevauth->getAuth($request);      // any PSR-7 request (Slim, Mezzio, Symfony via the PSR-7 bridge)

if ($auth->isSignedIn) {
    $auth->userId;            // user_…
    $auth->organizationId;    // org_…, or null
    $auth->has(permission: 'org:members:manage');
}

The token comes from the Authorization: Bearer … header, or from the session-bridge cookie for server-rendered pages (below).

getAuth() never throws. A missing, expired or tampered token means nobody is signed in, so a page can always decide what to render.

Refusing a request

use ZevAuth\Exception\AuthRefusedException;

try {
    $auth = $zevauth->requireAuth($request, requireMfa: true);
} catch (AuthRefusedException $e) {
    http_response_code($e->status);
    header('Content-Type: application/json');
    echo json_encode($e->toArray());
    exit;
}
Statuserror.codeWhen
401unauthorizedNot signed in
403session_task_pendingThe person must finish a task first, such as setting up two-step verification. The body lists pendingTasks
403mfa_requiredYou passed requireMfa: true and they haven’t verified a second factor

Pass allowPendingTasks: true only on the routes that help someone finish a task.

Caching the signing keys

Tokens are verified against your environment’s published keys, which rotate. The library caches them for at most ten minutes and picks up a rotation at once. Under PHP-FPM every request starts empty, so pass a shared PSR-16 cache. Without one, each request fetches the keys again.

$zevauth = ZevAuth::create(environmentId: '…', cache: $apcuOrRedisPsr16Cache);

Verifying a token yourself

use ZevAuth\Exception\VerifyException;

try {
    $claims = $zevauth->verifyToken($token);
} catch (VerifyException $e) {
    $e->errorCode;   // token_expired, invalid_audience, …
}

Only RS256 is accepted, the audience must be your environment, and clocks may differ by up to five seconds. A token from your test environment is refused on your live one.

Server-rendered pages

A browser asking for a page sends no Authorization header. The session bridge fixes that, without the server ever holding a refresh token.

  1. In the browser SDK, turn on the bridge:

    <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>
  2. Mount the two routes:

    // POST /zevauth/session
    $response = $zevauth->handleSessionBridge($request);
    
    // GET /zevauth/handshake
    $response = $zevauth->handshakePage($request);
  3. Read pages through authenticateRequest():

    ['auth' => $auth, 'handshake' => $handshake] = $zevauth->authenticateRequest($request);
    if ($handshake) {
        return $handshake;   // the cookie lapsed: refresh in the browser and come straight back
    }

The bridge keeps a short-lived, HttpOnly cookie in step with the browser’s session. When that cookie lapses, the page sends the browser through a one-hop handshake that refreshes and returns. How it works, and why the server never refreshes, is in Server-rendered apps.

No PSR-7 in your app? The same logic is available on plain values through $zevauth->bridge: handle(), readAuth(), handshakeFor() and SessionBridge::handshakeHtml().

WordPress

There’s no plugin to install: use the library from your theme or a small plugin of your own.

// wp-content/plugins/zevauth/zevauth.php
require __DIR__ . '/vendor/autoload.php';

use ZevAuth\ZevAuth;

function zevauth(): ZevAuth {
    static $instance;
    return $instance ??= ZevAuth::create(
        environmentId: ZEVAUTH_ENVIRONMENT_ID,
        publishableKey: ZEVAUTH_PUBLISHABLE_KEY,
    );
}

// The session bridge, at /zevauth/session and /zevauth/handshake.
add_action('rest_api_init', function () {
    register_rest_route('zevauth/v1', '/session', [
        'methods' => 'POST',
        'permission_callback' => '__return_true',   // the bridge checks its own header and origin
        'callback' => function (WP_REST_Request $wpRequest) {
            $result = zevauth()->bridge->handle(
                'POST',
                $wpRequest->get_header('x-zevauth-bridge'),
                $wpRequest->get_header('origin'),
                home_url('', 'https'),
                $wpRequest->get_body(),
            );
            foreach ($result->cookies as $cookie) {
                header('Set-Cookie: ' . $cookie->toHeader(), false);
            }
            return new WP_REST_Response($result->body, $result->status);
        },
    ]);
});

// In a template:
$auth = zevauth()->getAuthFromGlobals();
if ($auth->isSignedIn) { … }

Point the browser SDK’s sessionBridge.url at /wp-json/zevauth/v1/session. Serve the handshake page the same way, from SessionBridge::handshakeHtml($_GET['redirect_url'] ?? '/', …), and pass handshakePath: to ZevAuth::create() to match.

Backend API

use ZevAuth\ZevAuthClient;

$client = new ZevAuthClient(getenv('ZEVAUTH_SECRET_KEY'));   // sk_live_…

$user = $client->users->get('user_…');
$client->users->update('user_…', ['publicMetadata' => ['plan' => 'pro']]);
$client->users->setBlocked('user_…', true);
$client->users->resetMfa('user_…', revokeSessions: true);

foreach ($client->users->iterate(query: 'ada') as $user) {
    // every match, a page at a time
}

$org = $client->organizations->create(['name' => 'Acme']);
$client->organizations->addMember($org['id'], userId: 'user_…', role: 'org:admin');

Your secret key belongs on your server only. The client refuses a publishable key when it’s constructed.

Errors and retries

use ZevAuth\Exception\ApiException;

try {
    $client->users->get('user_missing');
} catch (ApiException $e) {
    $e->errorCode;   // not_found
    $e->status;      // 404
    $e->requestId;   // quote this to ZevAuth support
}

Rate limits (429) and gateway errors (502, 503, 504) are retried up to twice, waiting as long as Retry-After asks. A 500 is never retried, because the request may already have run. Each attempt times out after 15 seconds.

Webhooks

use ZevAuth\Exception\WebhookException;
use ZevAuth\Webhooks\Webhook;

try {
    $event = Webhook::constructEvent(
        payload: file_get_contents('php://input'),   // the raw body
        signature: $_SERVER['HTTP_ZEVAUTH_SIGNATURE'] ?? '',
        secret: getenv('ZEVAUTH_WEBHOOK_SECRET'),    // whsec_…
    );
} catch (WebhookException $e) {
    http_response_code(400);
    exit;
}

match ($event['type']) {
    'user.created' => …,
    default => null,
};

Verify the raw body. The signature covers the exact bytes ZevAuth sent, so a body that was decoded 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 library implements the same contract as the ZevAuth libraries for Node.js, Python, Ruby, Go and Java, and passes the same conformance tests. Server SDKs explains what that guarantees.

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

Updated at, Sunday, November 1, 2026