---
title: Server-rendered apps
description: How the session bridge lets Laravel, Rails, Django, Spring and other server-rendered apps see the ZevAuth session on page requests.
---

A ZevAuth session lives in the browser. The SDK keeps it there and sends
the access token with the requests it makes. That covers single-page apps
and API calls.

A server-rendered page is different. The browser asks for
`/dashboard` itself, with no `Authorization` header, so your server
can't tell who is asking before it renders. The **session bridge** fixes
that by handing the session to your server in a cookie of your own.

## How it works

1. You turn the bridge on in the browser SDK:

   ```js
   ZevAuthUI.init({
     publishableKey: 'pk_live_...',
     sessionBridge: { url: '/zevauth/session' },
   });
   ```

   It takes the same option in React (`<ZevAuthProvider sessionBridge=…>`),
   Vue, and `createZevAuth`.

2. Whenever the session's access token changes (sign-in, every refresh,
   switching organization, sign-out), the SDK posts it to that URL on
   your server.

3. Your server's bridge endpoint (one line with any ZevAuth server SDK)
   verifies the token and stores it in an **HttpOnly** cookie on your
   domain.

4. Page requests carry the cookie, and your server reads the session from
   it the same way it reads an `Authorization` header.

> **Note:**
> The browser stays in charge of the session. Your server never receives
> the refresh token and never refreshes anything; it only ever sees access
> tokens, and only the browser makes new ones.

## Why your server never refreshes

ZevAuth rotates refresh tokens on every use, and treats a refresh token
used twice as stolen: it ends the session. A server refreshing alongside
the browser would race it, and the loser would sign the person out. So
the refresh token never crosses the bridge.

## When the cookie has expired: the handshake

Access tokens live for minutes, so the token in the cookie is often
expired by the time someone opens a page. Your server then answers with
a **handshake** instead of the page:

1. The server redirects to a small handshake page on your site,
   remembering where the person was going.
2. The handshake page loads the ZevAuth SDK, which refreshes the session
   if the browser has one, posts the new token over the bridge, and goes
   back.
3. The original page now renders signed in.

This takes a fraction of a second and the person sees no sign-in screen.
It's safe in two ways:

- **No loops.** The return trip is marked. If the browser had no session
  either, the page renders signed out instead of trying again.
- **No open redirect.** The handshake only returns to a path on your own
  site.

API calls never get a redirect. They get a `401` with
`session_refresh_required`; send the SDK's token in `Authorization` for
those instead.

## The cookies

| Cookie | Holds | Lifetime |
| --- | --- | --- |
| `__Host-zevauth_session` | The access token | Until the token expires (minutes) |
| `__Host-zevauth_client` | `1`: "this browser has a session" | 30 days, cleared on sign-out |

Both are `HttpOnly`, `Secure` and `SameSite=Lax`, so scripts on the page
can't read them and other sites can't use them.

The second cookie holds no credential. It exists because the session
cookie deletes itself when the token expires. Without a second signal,
"the session expired" and "nobody ever signed in" would look the same, and
the handshake could never be offered.

**Subdomains.** To share the session across `app.example.com` and
`admin.example.com`, set the cookie domain to `.example.com` in your
server SDK. The cookies are then named `__Secure-…` and carry `Domain=`.
List every page origin as allowed.

**Local development over plain `http://`.** Turn `secure` off in your
server SDK, since Safari refuses secure cookies on `http://localhost`.
The cookies then drop their prefix. Never turn it off in production.

## Security

- **Login CSRF.** Another site could try posting its own valid token to
  your bridge, to sign your visitor in as someone else. The bridge refuses
  any post without the SDK's `x-zevauth-bridge: 1` header (a plain
  cross-site form can't add it), and any post whose `Origin` is not one of
  yours.
- **Every token is verified** before it is stored. A token that fails
  clears the cookies.
- **Revocation.** The cookie holds a stateless token. A session revoked
  elsewhere is still accepted by your server until that token expires,
  within minutes. Routes that must react at once should check with the API.
- **Content Security Policy.** The handshake page runs one inline script and
  loads `@zevauth/ui`. Under a strict policy, serve your own copy of the
  script and allow it.

## Signing out

Signing out with the SDK posts a clear to the bridge, which removes both
cookies. If your server also has its own sign-out route, clear the two
cookies there too, but end the ZevAuth session from the browser with the
SDK's `signOut()`.

## Multiple tabs

Each tab posts the token it holds. The posts are idempotent and the
newest wins, so tabs never fight over the cookie.
