Build with Veri.

Veri is built for developers who want to add high-assurance identity verification without asking users to hand over their data.

Integration Guide

Contents

1. What is Veri

Your users have to prove who they are, and today that usually means handing you their email address — which you then have to store, protect, and justify holding. Veri removes that trade. A user proves they control a verified email address at a real domain, and you find out which domain without ever learning the address. Fewer personal details on your side, less to leak, and the same confidence that a real person with a real, verified mailbox is signing in.

After a successful sign-in your app receives a small set of claims. The important ones are sub, a stable identifier for that person on your site only; email_domain, the real verified domain such as gmail.com, which is what you show the user; and veri_credential_type, telling you how strong the proof was. You never receive, and cannot request, the user's actual email address — or their wallet address.

"Sign in with Veri" is a standard OpenID Connect provider (authorization code flow, RS256-signed ID tokens, pairwise subjects). Use whatever OIDC library your stack already has, or plug Veri into an off-the-shelf app that supports a generic OIDC provider.

2. How it works

  1. Your app redirects the user to Veri's authorization endpoint.
  2. Veri opens the user's vault, which names your app and shows exactly what you will learn. If the user holds more than one credential, they choose which one to share.
  3. On approval, Veri redirects back to your registered redirect URI with a one-time authorization code and your state.
  4. Your server exchanges the code for tokens, authenticating with your client secret.
  5. Your app verifies the ID token and reads the credential. Veri mints it from the user's credential as recorded on-chain at that moment.

3. Before you start

  • A client_id and client_secret, issued instantly — no waiting and no emailing us:
    1. Click Get client credentials on getveri.co/developers.
    2. Enter your email, your app's name and its redirect URIs.
    3. Type in the 6-digit code we email you.
    4. Copy your client ID and secret straight into your .env file. The secret is shown once.
    One set of credentials per email every 24 hours.
  • Your redirect URI(s), registered with Veri. Matching is exact string equality — there are no wildcards and no localhost exception, so register every port you develop on — http://localhost:3000/callback must be registered as its own entry, and https://example.com/callback and https://example.com/callback/ are different URIs. Use HTTPS in production; the authorization code travels on this URL.
  • PKCE with S256. Clients registered today are required to send a code_challenge; a request without one is refused.
  • An OIDC library for your stack — see Section 4.

4. Quick start

All four use the same environment variables. SESSION_SECRET is any long random string; the Express and FastAPI examples sign their session cookie with it.

.env
VERI_ISSUER=https://id.veribeta.io
VERI_CLIENT_ID=your_client_id
VERI_CLIENT_SECRET=your_client_secret
VERI_REDIRECT_URI=http://localhost:3000/callback
SESSION_SECRET=a_long_random_string
Terminal
npm install openid-client

openid-client is the OpenID-Foundation-certified Node implementation.

server.ts
import { createServer } from "node:http";
import * as client from "openid-client";

// STEP 1 — Discover Veri. Fetches /.well-known/openid-configuration and checks
// the document's issuer matches the host it came from. Do this ONCE at boot.
const config = await client.discovery(
     new URL(process.env.VERI_ISSUER!),
     process.env.VERI_CLIENT_ID!,
     process.env.VERI_CLIENT_SECRET!,
     undefined,
     // Also verify the id_token's signature. OFF BY DEFAULT: the spec lets a client
     // rely on TLS instead. One line, and worth it.
     { execute: [client.enableNonRepudiationChecks] },
);

// STEP 2 — Build the authorization URL with PKCE, state and nonce.
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();

// Store all three server-side (session or httpOnly cookie). Never in the URL.
const pending = new Map([[state, { nonce, codeVerifier }]]);

const authUrl = client.buildAuthorizationUrl(config, {
     redirect_uri: process.env.VERI_REDIRECT_URI!,
     // Request what you read: openid is required; email_domain is Veri's own scope.
     scope: "openid email email_domain",
     code_challenge: codeChallenge,
     code_challenge_method: "S256",
     state,
     nonce,
});
console.log("Sign in:", authUrl.toString());

// STEP 3 — Handle the callback.
createServer(async (req, res) => {
  const url = new URL(req.url!, "http://localhost:3000");
  if (url.pathname !== "/callback") return res.writeHead(404).end();

  const session = pending.get(url.searchParams.get("state") ?? "");
  if (!session) return res.writeHead(400).end("Unknown state");

  // STEP 4 — Exchange the code AND verify the token in one certified call.
  // Checks the signature, iss, aud, exp, plus the nonce and PKCE bindings.
  const tokens = await client.authorizationCodeGrant(config, url, {
      expectedState: url.searchParams.get("state")!,
      expectedNonce: session.nonce,
      pkceCodeVerifier: session.codeVerifier,
  });

  // STEP 5 — Read the credential. See Section 6 for what each claim means.
  const claims = tokens.claims()!;
  console.log("email_domain:", claims.email_domain);
  console.log("credential type:", claims.veri_credential_type);
  console.log("user key:", { issuer: config.serverMetadata().issuer, sub: claims.sub });

  res.end(`Verified ${claims.email_domain} address`);
}).listen(3000);
Terminal — run it
npm pkg set type=module          # the file uses import and top-level await
node --env-file=.env server.ts   # Node 22.18+ runs .ts directly; older Node: npx tsx server.ts

5. Endpoints & tokens

Everything below is published in the discovery document, which every OIDC library reads for you. Point your library at the issuer and let it discover the rest.

Discovery
https://id.veribeta.io/.well-known/openid-configuration
EndpointURL
Authorizationhttps://id.veribeta.io/api/oidc/authorize
Tokenhttps://id.veribeta.io/api/oidc/token
UserInfohttps://id.veribeta.io/api/oidc/userinfo
JWKShttps://id.veribeta.io/.well-known/jwks.json
SettingValue
FlowAuthorization code only — response_type=code, grant_type=authorization_code. No implicit or hybrid flow, and no refresh tokens.
Scopesopenid is required. Also supported: email, email_domain, profile. Request openid email email_domain.
PKCES256 only. plain is rejected.
Client authAt the token endpoint: client_secret_basic (HTTP Basic, the OIDC default) or client_secret_post (in the body). Both work.
ID tokenJWT signed with RS256; verify it against the JWKS. Valid for 1 hour (exp = iat + 3600).
Access tokenOpaque, valid for 1 hour, usable only at the UserInfo endpoint. Returned with token_type: Bearer and expires_in: 3600.
Authorization codeSingle use, expires after 10 minutes, and bound to the exact redirect_uri and PKCE verifier of its request.
Subject typepairwise — see the warning in Section 6.

The UserInfo endpoint returns the same claims as the ID token (except the token-only iss, aud, iat, exp and nonce) for clients that read profile data there instead. It accepts the access token as Authorization: Bearer on GET or POST.

6. Reading the credential

Once your library has verified the token you will see the standard OIDC claims — iss, aud, sub, iat, exp, nonce — plus a few that are specific to Veri and that no OIDC documentation covers. Here is what each one means and what to do with it.

ClaimWhat it isExampleWhat to do with it
subA pairwise identifier, unique to this user on your site9f2c81a4e0… (64 hex characters)Use as your user ID. Store the issuer alongside it. A sub without its issuer cannot be matched if Veri ever migrates issuers.
emailA pseudonymous address at Veri's domain4f3a9c2e1b7d5a80@
id.veribeta.io
Never display it, never email it. It is stable per user per site but is not a real inbox. It exists for platforms that refuse to create an account without an email claim.
email_verifiedAlways truetrueVeri does not mint a token for a user without a verified credential, so you can trust it.
email_domainThe real verified domaingmail.comShow this to the user. "Verified gmail.com address" is the right display string.
name, preferred_usernameA pseudonymous handle derived from the email claimveri-4f3a9c2eFor apps that require a display name or username to create a profile. It reveals nothing about the user.
veri_credential_typeHow strong the proof wasemail_address_v2email_address_v2 proves control of a specific mailbox; email_domain_v1 proves only that they hold an address at the domain. Most apps just check email_verified.
veri_credentialsThe credentials disclosed in this sign-in, as objects[{"type": "email_address_v2", "domain": "gmail.com"}]Sorted strongest first. If the user chose a credential to share, this holds only that one, and email_domain and veri_credential_type describe it too.

WarningThe sub is different on every site

The same Veri user gets a different sub on your site than on any other. That is by design — it means nobody can correlate your users against another service. Store the issuer alongside the sub and use the pair as your account key. A future issuer migration changes every sub, and if you stored the sub alone you will silently stop recognising returning users.

const userKey = { issuer: "https://id.veribeta.io", sub: claims.sub }; // right
const userKey = claims.sub; // wrong

Two things worth knowing. veri_credentials holds objects, not strings — each has a type and a domain. And a user's sub and pseudonymous email are derived from their vault, not from the credential, so whichever credential they choose to share, they land in the same account on your site.

Preview

Device possession & token holdings

On the staging issuer (https://id-staging.veribeta.io), a sign-in can also carry two claims that production does not issue yet. Both are absent on an ordinary sign-in, so treat them as optional.

  • veri_mesh — what was proven during this sign-in: credentials (for example device_possession_v1, or token_holder_v1 with an asset_id and threshold), per-check outcomes (asserted, not_held, rejected, unavailable), plus complete, degraded and completed_at. If degraded is true, a check could not run — never read that as "the user lacks it".
  • amr — ["swk"] (RFC 8176) only when the user proved possession of their vault key from their device. Gate on this if you require the stronger proof.
Preview claims
"veri_mesh": {
  "credentials": [{ "type": "device_possession_v1" }],
  "outcomes": [{ "verifier": "device_possession", "status": "asserted" }],
  "complete": true,
  "degraded": false,
  "completed_at": 1791000000
},
"amr": ["swk"]

Verified Token Holder credentials reach a relying party only through veri_mesh; they never appear in veri_credentials.

7. Security checklist

  1. PKCE — always use S256. Veri rejects plain, and new clients must send a challenge.
  2. Nonce — generate a random nonce, store it in the session, verify the token's nonce matches.
  3. State — generate a random state, store it in the session, verify it in the callback to prevent CSRF.
  4. Signature — verify the ID token's RS256 signature against the JWKS. Some libraries skip this by default when the token arrives over TLS; turn it on.
  5. Issuer — pin iss to https://id.veribeta.io and reject anything else.
  6. Audience — pin aud to your client_id and reject tokens meant for others.
  7. Expiry — check exp. Most libraries do this for you; confirm yours does.
  8. Secret — keep the client_secret and the token exchange on your server, never in a browser or app bundle.
  9. Sub storage — store the issuer alongside the sub. See Section 6 for why.
  10. HTTPS — use HTTPS for your redirect URI in production, never plain HTTP.

8. Error handling

Errors reach you in three places, and only the first two come from Veri.

From the token endpoint

JSON bodies of the form { "error": "…", "error_description": "…" }.

ErrorWhat it meansWhat to do
invalid_clientHTTP 401. Wrong client_id or client_secret, or the client is inactive.Check your environment variables.
invalid_grantThe code is unknown, already used or expired (10 minutes); the redirect_uri differs from the original request by even a byte; or PKCE verification failed.Restart the sign-in flow. If it repeats, compare the redirect URI and code verifier you send.
credential_requiredThe user holds no verified email credential.Ask them to verify an email in their Veri vault, then sign in again.
invalid_requestA required parameter is missing or malformed.Fix the request. Your library normally prevents this.
unsupported_grant_typeAnything other than authorization_code.Use the authorization code flow.
rate_limit_exceededHTTP 429. More than 30 token requests a minute for your client.Back off and retry.

On your callback URL

Veri redirects back only to a registered URI. Handle error=access_denied on your callback (it arrives with your state and an error_description): that is the standard OIDC response to a declined sign-in, and it is how Veri reports a user who declines. Until that rollout completes, a user who declines is not sent back to your app.

Shown to the user instead

If the authorization request itself is invalid — an unknown or inactive client_id, an unregistered redirect_uri, a missing PKCE challenge, or a scope without openid — Veri shows the user an error page and does not redirect to you, because the redirect URI cannot be trusted. Nothing arrives at your callback. Test your configuration end to end before you ship.

Everything else — an expired ID token, a nonce or state mismatch, a network failure reaching Veri — is raised by your OIDC library, not by Veri. Catch the library's error types rather than matching on error strings, and treat a nonce or state mismatch as a security signal, not a retry.

9. Known limitations

Browser-only apps.

Veri requires a confidential client, which means a client_secret, and a secret in a web page is a secret anyone can read and use to impersonate your app. A purely static site therefore cannot integrate directly. Add one small serverless function to hold the secret and do the token exchange — the IETF's OAuth 2.0 for Browser-Based Applications recommends this pattern over public clients in the browser anyway.

Python's standard library.

Cloudflare returns 403 to any client whose User-Agent begins with Python-urllib, on every Veri OIDC endpoint. This breaks PyJWT's PyJWKClient, which fetches through urllib, and the failure is a bare HTTP Error 403: Forbidden with nothing pointing at the cause. Authlib uses httpx and mozilla-django-oidc uses requests, so both are unaffected — use one of those rather than the stdlib HTTP client.

Sessions are yours.

There are no refresh tokens and no logout endpoint. The ID token proves the sign-in; after that, keep the user signed in with your own session, and sign them out by ending it — the Express example in Section 4 shows a three-line /logout.

Mobile apps.

React Native and other mobile frameworks need a different pattern: they cannot hold a client secret either, and they need a custom URI scheme or app link as the redirect. Contact us before you start rather than adapting the web flow.

10. Live demo

Try a live demo

See the full Sign in with Veri flow in under 30 seconds. The demo site learns only your verified email domain — never your address.

11. Getting help

  • Questions or problems: email team@getveri.co — let's solve problems now, not later.
  • Register a client: use Get client credentials in Section 3. To change a redirect URI or replace a lost secret, email us your client_id.
  • This page is the current version of the integration guide. If you were sent a copy, check it against this page.

Ready to integrate?

Get client credentials in about a minute, or download the guide as a PDF to share with your team.