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

```bash
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](/sdk/laravel/) instead. It
builds on everything here and wires it into middleware, guards and Blade
for you.

## Who is signed in

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

```php
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](/concepts/mfa/). 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.

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

### Verifying a token yourself

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

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

2. Mount the two routes:

   ```php
   // POST /zevauth/session
   $response = $zevauth->handleSessionBridge($request);

   // GET /zevauth/handshake
   $response = $zevauth->handshakePage($request);
   ```

3. Read pages through `authenticateRequest()`:

   ```php
   ['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](/concepts/session-bridge/).

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.

```php
// 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.

> **Note:**
> Keep the keys out of your theme files: define `ZEVAUTH_ENVIRONMENT_ID`
> and the others in `wp-config.php`, or read them from the environment.

## Backend API

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

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

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

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