ZevAuth
Sign up

Delegated access to Zev products

The flow

Send the user to the authorize entry, receive the code on your callback, and exchange it for tokens. Plus how users add or remove targets later.

A connection takes one browser redirect and one server call. Both happen in your live environment, or in your test environment with your test keys.

1. Send the user to the authorize entry

GET /v1/partner/delegated/authorize

This is a full page navigation, not a fetch. A navigation cannot carry a header, so your publishable key goes in the query string. That is safe: a publishable key already ships in your browser bundle. Never put a secret key in a URL.

ParameterRequiredNotes
publishable_keyyesYour publishable key, pk_live_… (or pk_test_… to test). It selects your app; nothing else in the request can.
resourceyesThe product, for example zevcloud.
scopeyes, except with promptSpace-separated. The full set you want after this flow, not just what is new. See below.
redirect_uriyesExactly one of the callbacks you registered for this product.
stateyesUp to 512 characters, unguessable, one per attempt. Returned to you untouched.
code_challengeyesBase64url SHA-256 of your PKCE verifier. See PKCE and state.
code_challenge_methodyesS256. plain is refused.
promptnoconfigure to change targets only, reconfirm to confirm high-risk permissions again.
login_hintno, recommendedThe ZevID account id of the user you expect, when they signed in to your app with ZevID. See below.
// Server side: create the attempt, then redirect the browser.
import { randomBytes, createHash } from 'node:crypto';

const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(32).toString('base64url');

// Keep both server-side, tied to this user's session.
await saveAttempt(session.id, { state, verifier });

const url = new URL('https://api.zevauth.net/v1/partner/delegated/authorize');
url.search = new URLSearchParams({
  publishable_key: process.env.ZEVAUTH_PUBLISHABLE_KEY!, // pk_live_…
  resource: 'zevcloud',
  scope: 'zevcloud.services.read zevcloud.deploy',
  redirect_uri: 'https://app.example.com/zev/callback',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString();

return redirect(url.toString());

ZevAuth checks the key, that redirect_uri is registered for this product, and that state and an S256 PKCE challenge are present. Then it redirects the browser to ZevID. state and the PKCE challenge pass through unchanged. If redirect_uri is not registered, there is nowhere safe to send the user, so we show a page that says so instead of redirecting.

Tell ZevID who you expect

If your users sign in to you with ZevAuth’s Sign in with ZevID, you already know which ZevID account is theirs. Say so, in one of two ways:

  • From your server (recommended): call POST /v1/partner/delegated/authorize-url with your secret key and your own user_id. We look up their ZevID account and return the URL to send the browser to.
  • In the browser link: send login_hint, their ZevID account id (the zevid entry’s providerAccountId under externalAccounts on the user). It must belong to someone who signed in to this environment with ZevID.

Either way:

  • the user sees the consent screen straight away, with no second sign-in;
  • only that ZevID account can approve. If the browser is signed in to another one, ZevID asks the user to switch, and if they will not, you get error=access_denied&error_reason=account_mismatch;
  • at the code exchange we check that the connection came back for that account. If it did not, we revoke it and answer invalid_grant with reason: account_mismatch instead of handing you tokens. If the hint could not be recorded, the flow stops with error=server_error rather than carrying on unchecked; try again.

A login_hint that is not one of this environment’s ZevID sign-ins is refused with error_reason=invalid_login_hint. user_id is only accepted on the server call: a browser link is visible to anyone, and must not turn a user id into an account id.

Build the URL on your server

curl https://api.zevauth.net/v1/partner/delegated/authorize-url \
  -H "Authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "resource": "zevcloud",
    "scope": "zevcloud.services.read zevcloud.deploy",
    "redirect_uri": "https://app.example.com/zev/callback",
    "state": "…",
    "code_challenge": "…",
    "code_challenge_method": "S256",
    "user_id": "user_123"
  }'
{ "url": "https://…/v1/partner/authorize?…" }

Redirect the user’s browser to url straight away. It takes the same fields as the browser entry, as JSON. The answer is the same whether or not user_id exists or has signed in with ZevID: when there is no ZevID account to hint, the URL simply has none, and ZevID signs the user in. Errors are JSON: { "error", "error_description", "error_reason" }.

scope is the final set

Whatever you send replaces what the connection has. Ask for everything you want to hold after this flow:

  • A new permission: send the old ones plus the new one. The user sees only the new one to approve.
  • Fewer permissions: send only those you keep. The rest are dropped without asking the user.
  • Nothing new: the user sees a short screen to confirm their targets.

There is one connection per user per product. Authorizing again edits that connection; it never creates a second one.

2. The user consents on ZevID

ZevID signs the user in if needed, lists each permission in plain words, and asks them to pick at least one target, for example one or more ZevCloud teams. Targets the user may see but not choose are shown with the product’s reason.

Then ZevID redirects the browser straight to your redirect_uri:

https://app.example.com/zev/callback?code=zpc_…&state=…

or, if something went wrong or the user said no:

https://app.example.com/zev/callback?error=access_denied&error_description=…&error_reason=user_denied&state=…

error_reason is a machine word you can branch on; see Errors.

3. Handle the callback

  1. Compare state with the one you stored for this session. If it does not match, or there is none, stop. Do not exchange the code.
  2. If error is present, show it. For access_denied, the user chose not to connect; do not retry automatically. See Errors.
  3. Otherwise exchange the code at once, from the session that started the flow. It is valid for 60 seconds and only once.

4. Exchange the code

POST /v1/partner/delegated/token

Server side only, with your secret key. JSON or form-encoded.

curl https://api.zevauth.net/v1/partner/delegated/token \
  -H "Authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "zpc_...",
    "code_verifier": "the verifier you stored",
    "redirect_uri": "https://app.example.com/zev/callback"
  }'
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "zpr_...",
  "refresh_token_expires_in": 2592000,
  "scope": "zevcloud.deploy zevcloud.services.read",
  "grant_id": "6d1d...",
  "gv": 3,
  "resource": "zevcloud",
  "account_id": "3b9f...",
  "targets": [{ "id": "team_42", "name": "Acme Prod", "kind": "team" }],
  "auth_strength": "user_consent",
  "hr_exp": null,
  "high_risk": [],
  "tenant_mode": "live",
  "grant_expires_at": null
}

The response is ZevID’s, unchanged, and may gain fields. Ignore any you do not know.

FieldMeaning
access_tokenSend it to the product’s API. See Using the access token.
refresh_tokenStore it encrypted. It changes on every refresh. See Refresh tokens.
scopeWhat you may use now. It can be less than you asked for. Always read it.
grant_idThe connection. Key your storage on it; you need it to revoke.
gvThe connection’s version. It increases on every change.
account_idThe user’s ZevID account, the same id Sign in with ZevID gives you. Check it is the user you started the flow for.
targetsWhat the user picked. Store them; the access token does not carry them.
auth_strength, hr_exp, high_riskHigh-risk permissions. See High-risk permissions.
tenant_mode, grant_expires_attest or live, and when a test connection ends on its own. See Test environments.

Adding or removing targets later

Send the user through the same entry with prompt=configure. You can leave out scope to keep the current permissions:

https://api.zevauth.net/v1/partner/delegated/authorize
  ?publishable_key=pk_live_…&resource=zevcloud&prompt=configure
  &redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256

The user sees only the target picker, with current targets ticked. Whatever they leave ticked becomes the full set; anything unticked is removed. Exchange the new code as usual and replace the targets you stored.

Updated at, Wednesday, October 21, 2026