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;
}
| Status | error.code | When |
|---|---|---|
| 401 | unauthorized | Not signed in |
| 403 | session_task_pending | The person must finish a task first, such as setting up two-step verification. The body lists pendingTasks |
| 403 | mfa_required | You 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.
-
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> -
Mount the two routes:
// POST /zevauth/session $response = $zevauth->handleSessionBridge($request); // GET /zevauth/handshake $response = $zevauth->handshakePage($request); -
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