Your backend
Go
Verify sessions, manage users and organizations, receive webhooks and serve server-rendered pages from any Go server, with the standard library only.
go get github.com/zevop/zevauth-go
Go 1.22 or later. No dependencies beyond the standard library: it works
with net/http and with any router built on it (chi, gorilla/mux, the
standard ServeMux).
Who is signed in
import zevauth "github.com/zevop/zevauth-go"
verifier, err := zevauth.NewVerifier(zevauth.VerifierConfig{
EnvironmentID: os.Getenv("ZEVAUTH_ENVIRONMENT_ID"), // env_live_…
})
auth := verifier.GetAuth(r)
if auth.IsSignedIn {
auth.UserID // "user_…"
auth.SessionID // "sess_…"
auth.OrganizationID // "" when not acting in an organization
auth.OrganizationRole // "org:admin"
auth.Has(zevauth.HasCheck{Permission: "org:members:manage"})
auth.Claims.Email // every verified claim
}
GetAuth reads the Authorization: Bearer header that the browser SDK
sends with getToken(). It never fails for a missing, expired or forged
token: those all mean nobody is signed in. Share one Verifier per
environment; it is safe for concurrent use.
Middleware
mux := http.NewServeMux()
mux.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) {
auth := zevauth.AuthFromContext(r.Context())
fmt.Fprintf(w, "hello %s", auth.UserID)
})
// Require: refuse requests that are not signed in.
http.ListenAndServe(":8080", zevauth.Middleware(verifier, zevauth.MiddlewareOptions{Require: true})(mux))
Without Require, every request passes through with its Auth in the
context, and each handler decides.
Refusing a request
auth, refusal := verifier.RequireAuth(r, zevauth.RequireOptions{RequireMFA: true})
if refusal != nil {
refusal.ServeHTTP(w, r)
return
}
| Status | error.code | When |
|---|---|---|
| 401 | unauthorized | Not signed in |
| 403 | session_task_pending | The person still has to set up two-step verification. Pass AllowPendingTasks: true on the routes that help them do it |
| 403 | mfa_required | RequireMFA and the session did not pass a second factor |
The body is { "error": { "code": "…", "message": "…" } }, the same
envelope the ZevAuth API uses.
Verifying a token yourself
claims, err := verifier.Verify(ctx, token)
var verr *zevauth.VerifyError
if errors.As(err, &verr) {
verr.Code // "token_expired", "invalid_audience", …
}
errors.Is(err, zevauth.ErrTokenExpired) // also works
Only RS256 is accepted, whatever the token’s header claims. The issuer
comes from your environment’s discovery document, the audience must be
your environment id, and 5 seconds of clock skew is allowed. Keys are
cached for up to 10 minutes; a token signed by a new key triggers one
refetch, at most every 30 seconds. For tests and air-gapped servers, set
Issuer and JWKS to use a local key set instead of fetching.
Server-rendered pages
The session lives in the browser, so a page request carries no bearer token. The session bridge keeps the browser’s access token in your own HttpOnly cookie:
bridge := &zevauth.Bridge{Verifier: verifier}
mux.Handle("POST /zevauth/bridge", bridge)
mux.Handle("GET "+zevauth.DefaultHandshakePath, zevauth.HandshakeHandler(zevauth.HandshakeOptions{
PublishableKey: os.Getenv("ZEVAUTH_PUBLISHABLE_KEY"),
BridgePath: "/zevauth/bridge",
}))
// Reads the header, then the cookie, and runs the handshake when needed.
http.ListenAndServe(":8080", bridge.Middleware(zevauth.MiddlewareOptions{})(mux))
In the browser, pass sessionBridge: { url: '/zevauth/bridge' } to the
SDK. Your server never refreshes a session. When the cookie expires, the
middleware sends the page through a short handshake that lets the browser
refresh it and come straight back.
| Option | Default | |
|---|---|---|
AllowedOrigins | the request’s own origin | Origins that may post to the bridge |
CookieDomain | none | Share the cookies with subdomains |
Insecure | false | Plain http:// local development only |
HandshakePath | /zevauth/handshake |
Backend API
client, err := zevauth.NewClient(os.Getenv("ZEVAUTH_SECRET_KEY"), zevauth.ClientOptions{})
user, err := client.Users.Get(ctx, "user_…")
_, err = client.Users.SetBlocked(ctx, "user_…", true)
err = client.Users.ResetMFA(ctx, "user_…", zevauth.ResetMFAOptions{RevokeSessions: true})
org, err := client.Organizations.Create(ctx, zevauth.CreateOrganizationParams{Name: "Acme", CreatedBy: "user_…"})
_, err = client.Organizations.Update(ctx, org.ID, map[string]any{"requireMfa": true})
Every list has an iterator that fetches pages as you go:
it := client.Users.Iter(ctx, zevauth.ListUsersParams{Metadata: map[string]string{"plan": "pro"}})
for it.Next() {
fmt.Println(it.User().ID)
}
if err := it.Err(); err != nil { … }
Errors and retries
var apiErr *zevauth.APIError
if errors.As(err, &apiErr) {
apiErr.Code // "not_found", "rate_limited", "organization_permission_required", …
apiErr.Status // 0 for a network failure
apiErr.RequestID // quote this to ZevAuth support
}
429, 502, 503, 504 and network failures are retried twice, honouring
Retry-After. A 500 is never retried: the request may have run, and
retrying is how one new user becomes two. Each attempt times out after 15
seconds. Pass a context.Context to cancel.
Webhooks
mux.HandleFunc("POST /webhooks/zevauth", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := zevauth.ConstructWebhookEvent(body, r.Header.Get("zevauth-signature"), os.Getenv("ZEVAUTH_WEBHOOK_SECRET"), zevauth.WebhookOptions{})
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
switch event.Type {
case "user.created":
// json.Unmarshal(event.Data, &user)
}
w.WriteHeader(http.StatusNoContent)
})
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, PHP, Python, Ruby and Java, and passes the same conformance tests. Server SDKs explains what that guarantees.
Source and issues: github.com/zevop/zevauth-go.
Updated at, Sunday, November 1, 2026