§ Developers

Sign in with AIVerID.

Add AIVerID sign-in to your app with standard OAuth 2.0 and PKCE — and, if your service opens an API to AI agents, check the tokens they bring.

01At a glance

  • Standard OAuth 2.0 authorization code flow with PKCE (S256), for server apps with a client secret and for public clients without one.
  • Endpoints are discoverable from the server metadata (RFC 8414); tokens can be revoked (RFC 7009) and introspected.
  • Your app can ask for a verified identity with min_identity_level; the user is prompted to verify before consent.
  • The consent screen shows the user exactly what your app will receive before anything is shared.

02Quick Start Example

const AIVERID = 'https://aiverid-backend-production.up.railway.app';

// 1. Send the user to the authorization endpoint
const authUrl = new URL(`${AIVERID}/oauth/authorize`);
authUrl.searchParams.append('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.append('redirect_uri', 'YOUR_CALLBACK_URL');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('scope', 'profile email');
authUrl.searchParams.append('state', generateRandomState());
// Optional: require a verified identity for this flow (1-4).
// The user is prompted to step up before consent is granted.
// authUrl.searchParams.append('min_identity_level', '2');

window.location.href = authUrl.toString();

// 2. Exchange the code for tokens (server-side — never in the browser)
const code = new URLSearchParams(window.location.search).get('code');
const response = await fetch(`${AIVERID}/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code,
    client_id: 'YOUR_CLIENT_ID',
    client_secret: 'YOUR_CLIENT_SECRET', // omit for public clients; send code_verifier instead
    redirect_uri: 'YOUR_CALLBACK_URL'
  })
});
const { access_token, refresh_token } = await response.json();

// 3. Read the profile
const me = await fetch(`${AIVERID}/oauth/userinfo`, {
  headers: { Authorization: `Bearer ${access_token}` }
}).then(r => r.json());

// me.sub / me.aiverid → the member number (opaque string, e.g. "TH...")
// me.picture          → profile photo URL (not me.avatar_url)
// me.verification_level → 1 Basic · 2 Verified · 3 Approved · 4 Certified

03Callback URLs

Your redirect_uri is compared by exact string match — no host aliasing, no path normalisation, no trailing-slash forgiveness. Register and send the canonical host your app actually serves, character for character.

This is the integration bug that costs the most time, because it fails after a successful authorization and looks like a broken state check: register https://www.example.xyz/callback while your app canonicalises www to the apex, and the browser lands on the redirect, gets bounced to the apex, and arrives at your callback without the session cookie you set on the apex — so your own state comparison fails and loops. Nothing in the AIVerID response says "host mismatch", and nothing can: the hub redirected to precisely the URI you registered. Check the canonical host first whenever a callback reports an invalid or missing state.

If your app answers on both hosts, register both — but still send the canonical one. Give each distinct flow its own callback path (/oauth/callback for sign-in, a separate one for any agent or elevated-scope grant) so the two can never share state or be confused in a log.

04Signing Out (required)

Clearing your own session is not enough — the tokens AIVerID issued you stay valid until they expire. Your sign-out MUST also revoke them (RFC 7009). Revoke each token separately: revocation matches an exact token, so the access token and the refresh token need one call each. The examples here post JSON; the endpoint also accepts standard application/x-www-form-urlencoded bodies, so a generic RFC 7009 client library works unmodified.

Two rules that are easy to get wrong: session cookies must be cleared server-side — document.cookie cannot touch an httpOnly cookie and fails silently, leaving a "signed-out" user fully signed in; and never send your client secret from browser code — revocation accepts client_id + token without a secret, precisely so public and browser-side clients can call it.

// server-side route — e.g. POST /api/auth/logout
// async so a synchronous throw becomes a rejection allSettled can absorb
const revoke = async (token) =>
  fetch('https://aiverid-backend-production.up.railway.app/oauth/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ client_id: CLIENT_ID, token }),  // no secret needed
  })

// best-effort: an AIVerID outage must never trap a user in a session
await Promise.allSettled([revoke(accessToken), revoke(refreshToken)])

// then clear YOUR session cookies here, server-side, whatever happened above

Six failure modes found in real Ver* integrations — check all of them before calling sign-out done:

  • Prove the revoke call receives a real token. A sign-out path that reads a cookie nothing ever sets gets undefined, returns early, and revokes nothing — while looking finished in the diff. Add a route-level test asserting the token passed to /oauth/revoke is the one your session actually stores, not undefined.
  • Sign-out must be POST-only with an origin check. A logout route that accepts GET is a CSRF primitive: with SameSite=None cookies (any PWA), a foreign <img src=".../signout"> signs your users out — and once you revoke correctly, force-revokes their hub tokens too.
  • Never fail open on missing config. if (!CLIENT_ID) return silently disables revocation while login keeps working. The client_id is public by design — fall back to the literal string instead of skipping the call.
  • Sign-out must work on an expired session. Local sessions typically expire with the access token (~1 hour) while the refresh token lives 7 days. A logout that reads the session strictly finds nothing after the first hour and revokes nothing. When collecting tokens to revoke, verify the session cookie's signature but ignore its expiry.
  • Audit every session-clearing path, not just the route named "logout". Apps accumulate second exits — a session DELETE endpoint, a legacy sign-out helper — that clear cookies without revoking. Grep for everything that clears the session cookie and route them all through one revoke-before-clear function, with a regression test per path.
  • Clear the cookie with the same attributes that set it. A cookie set with Domain=.example.xyz is not deleted by a host-only expiry — the browser keeps the original and the "signed-out" user stays signed in on every subdomain. Expire with the exact Domain/Path used at login (or clear both variants), and assert the Set-Cookie headers in a production-mode test.

This ends the session for your application only. Users who want to sign out of everything at once can do it from their AIVerID dashboard — every connected app can be revoked individually, or all at once with Sign out everywhere.

05Agent Tokens (for services opening an agent API)

Skip this section unless you are exposing an API that an AI agent calls on a user's behalf. Ordinary sign-in needs nothing here.

AIVerID issues a distinct kind of access token for a delegation: the user names an agent, approves a narrow set of scopes, and gets one revoke button per agent in their dashboard — killing that agent without touching their own session or any other agent. Your job at the other end is to accept only those tokens on your agent API.

The rule: fail closed on all four

Accept a request on your agent surface only when every one of these holds at once. Any single one missing is a refusal:

  • introspection returns active: true
  • token_use is "agent"
  • grant_id is present
  • aud equals your own resource identifier, compared as an exact string

And three things you must never do: never accept a token with no aud — no "missing means fine" fallback, that gap is the entire reason this exists; never treat scope alone as proof of agency; and never verify the JWT yourself — the signing secret never leaves AIVerID, so introspection is the only verification path, which is also what makes a revoke take effect in seconds rather than at expiry.

Why so strict: the agent_name on an authorization request is supplied by the caller, so it is a label, not evidence. Anyone who can drive your client's OAuth flow can leave it out and get an ordinary web token carrying the same scopes. The only fields that are evidence are the ones AIVerID stamps and you cannot forge — token_use, grant_id, and aud.

Register your resource identifier first

A client that has not registered a resource cannot open an agent flow at all — the authorization request is refused rather than producing a token no one is allowed to accept. Send us the identifier you want and we add it to your client.

RFC 8707 requires the value to be an absolute URI and forbids a fragment component. The rest of what we enforce is stricter than the RFC, and we would rather you knew which is which than mistake our policy for the standard:

  • https only — the RFC does not constrain the scheme.
  • No query component at all, where the RFC merely says SHOULD NOT — an audience that has to be compared with query parameters attached is an audience comparison waiting to go wrong.
  • No userinfo, and no bare ?, # or @ markers; the RFC is silent on these, but URL parsers disagree about them, so we reject the raw string before one gets to normalize it.
  • 256 characters maximum — our limit, not the RFC's.
  • Your agent scopes are bound to your resource — also ours, not the RFC's. A token whose aud is your resource can carry only the yourapp:* scopes registered for that surface (plus profile and email where the client holds them). A request that names your resource and asks for another service's scopes is refused with invalid_scope before the consent screen renders. Keep comparing scopes exactly on your side all the same.
  • One resource per surface, and every token names exactly one. The RFC permits a token to carry several audiences; we refuse, because one token accepted at several audiences is the confused-deputy problem the parameter exists to prevent. A service that opens a second surface (an agent API and a remote MCP server, say) registers a second identifier and each token binds to the one its request named.

Keep version numbers in your endpoint paths, not in the identifier, so v1 costs you nothing later.

Once registered, resource becomes mandatory on your agent authorization requests and must match the registered value exactly; sending a different one, or none, is rejected with invalid_target. Your ordinary web sign-ins are unaffected and continue to receive tokens with no aud.

Introspect on every call

POST https://aiverid-backend-production.up.railway.app/oauth/introspect
Content-Type: application/x-www-form-urlencoded

token=<the bearer token>&client_id=<yours>&client_secret=<yours>

// active response — agent fields appear only for agent tokens
{
  "active": true,
  "sub": "TH...",
  "client_id": "aiv_yourapp_...",
  "scope": "yourapp:read yourapp:propose",
  "exp": 1787136103,
  "iat": 1787132503,
  "token_type": "access_token",
  "username": "TH...",
  "verification_level": 1,
  "verification_badge": "basic",
  "aud": "https://yourapp.xyz/api/agent",
  "token_use": "agent",
  "grant_id": "agt_0123456789abcdef0123456789abcdef",
  "act": { "sub": "agt_0123...", "name": "Claude Code on Job's laptop" }
}

Introspect with the same client_id the token was issued to — AIVerID answers active: false for another client's token, so one service can never probe another's sessions. Cache the result for at most 60 seconds: longer and a revoked agent keeps working, and your users' "last used" column goes stale, because introspection is what marks an agent as active.

Publish your resource metadata

Host this yourself so an agent can discover where to authenticate without being told (RFC 9728). The path of your resource identifier is inserted after the well-known suffix — an identifier of https://yourapp.xyz/api/agent is published at /.well-known/oauth-protected-resource/api/agent, not at the bare well-known path. An identifier with no path uses the bare path.

GET https://yourapp.xyz/.well-known/oauth-protected-resource/api/agent
Content-Type: application/json
Cache-Control: public, max-age=3600
Access-Control-Allow-Origin: *

{
  "resource": "https://yourapp.xyz/api/agent",
  "authorization_servers": [
    "https://aiverid-backend-production.up.railway.app"
  ],
  "scopes_supported": ["yourapp:read", "yourapp:validate", "yourapp:propose"],
  "bearer_methods_supported": ["header"],
  "resource_name": "YourApp Agent API",
  "resource_documentation": "https://yourapp.xyz/docs/agent"
}

resource is the only required member, and its value must be byte-identical to what you registered with us. Do not publish jwks_uri: AIVerID signs with HS256 and there are no public keys to fetch — a JWKS pointer would invite exactly the offline verification that makes revocation stop working.

Refuse in a way the agent can act on

// expired, revoked, wrong audience, or not an agent token → 401
WWW-Authenticate: Bearer realm="yourapp.xyz",
  error="invalid_token",
  error_description="The access token expired",
  resource_metadata="https://yourapp.xyz/.well-known/oauth-protected-resource/api/agent"

// authenticated, but the grant lacks the scope → 403
WWW-Authenticate: Bearer realm="yourapp.xyz",
  error="insufficient_scope",
  scope="yourapp:propose",
  resource_metadata="https://yourapp.xyz/.well-known/oauth-protected-resource/api/agent"

The resource_metadata pointer is what lets a well-built agent recover on its own instead of waiting for a human to reconfigure it. Two details from RFC 6750: a request carrying no credentials at all should get the bare challenge with no error code, and a wrong audience is invalid_token — say that it failed, never which of the four checks it failed, or you have written an oracle.

Before you open the surface

  • Test that each of the four checks refuses on its own. Write one test per check that presents a token missing only that property and asserts a 401 — then break the check and confirm the test goes red. A check with no test that has ever failed is a check you have not written.
  • Give agents their own scopes, and only those. The account-level scopes tokens and business are refused to agents at the hub, before consent renders — an agent authorization that asks for either is rejected outright, so do not design around it. Name your agent scopes after your service (yourapp:read, yourapp:propose) and register them with us.
  • The agent proposes, the human commits. Anything irreversible needs a person to confirm, and the confirmation screen must show everything that will actually happen — not a summary of it. A summary is where a malicious or confused agent hides the rest.
  • Use a separate callback path for the agent grant so an agent authorization and a normal sign-in can never be confused in your logs.
  • Revoke on sign-out exactly as the section above requires — an agent grant outlives the browser session that created it.

Letting claude.ai or ChatGPT connect to your MCP server

If you expose your agent API as a remote MCP server, a user can add it to claude.ai, Claude Desktop or ChatGPT as a custom connector by pasting its URL. In that flow the host is the OAuth client, not your service. AIVerID does not offer open dynamic client registration — an anonymous registrant would choose its own name on the consent screen, which is a phishing kit on the one screen every user trusts. Instead each host has a pre-registered public client, which is the alternative the MCP authorization specification itself describes. A host that mints a callback per connector (ChatGPT on the web) can register through RFC 7591 at /oauth/register, but only for callbacks on chatgpt.com, claude.ai or claude.com, and the client it gets inherits its name, scopes and public PKCE settings from that host's entry:

client_id   aiv_claude     redirect  https://claude.ai/api/mcp/auth_callback
client_id   aiv_chatgpt    redirect  https://chatgpt.com/connector_platform_oauth_redirect

client_secret   none — public client, PKCE S256 required
resource        the canonical URI of your MCP server, exactly as your
                protected-resource metadata publishes it

The user enters the client id in the host's advanced OAuth settings and leaves the secret blank. There is no secret on purpose: an id that every user of a connector pastes cannot keep a companion secret, and a secret that is public knowledge is worse than none. Possession is proven by PKCE, which both hosts implement.

Every authorization from these clients is an agent flow. The host never names itself, so AIVerID supplies the name — the grant appears as Claude or ChatGPT under the user's connected agents with its own revoke button, the token carries token_use: "agent", a grant_id and an aud equal to your MCP server URI, and the four checks above apply unchanged. The account scopes tokens and business are refused before consent, exactly as for any agent.

To open your MCP server to these hosts, send us its canonical URI and the scopes it accepts; we add them to the host clients. One caution on the identifier: MCP hosts send the URL the user pasted as the resource, so publish that exact value in your protected-resource metadata and compare aud against it at the MCP surface. If your MCP handler calls an inner API of your own, mint or map credentials there — never forward the host's token, whose audience is the MCP server and nothing else.

06Server Metadata

Endpoints, scopes and supported flows are published as OAuth 2.0 Authorization Server Metadata (RFC 8414), so your client library can discover them instead of hard-coding URLs. AIVerID is OAuth 2.0 with PKCE — not OpenID Connect: there is no id_token and no JWKS, and identity claims come from the userinfo endpoint.

GET https://aiverid-backend-production.up.railway.app/.well-known/oauth-authorization-server

{
  "issuer": "https://aiverid-backend-production.up.railway.app",
  "authorization_endpoint": ".../oauth/authorize",
  "token_endpoint": ".../oauth/token",
  "userinfo_endpoint": ".../oauth/userinfo",
  "introspection_endpoint": ".../oauth/introspect",
  "revocation_endpoint": ".../oauth/revoke",
  "scopes_supported": ["business", "email", "profile", "tokens", ...],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
  "revocation_endpoint_auth_methods_supported": ["client_secret_post", "none"],
  "openid_connect_supported": false
}

Read this document rather than copying values from here — scopes_supported grows as applications register resource scopes, and requesting a scope your client is not registered for is rejected with invalid_scope.

07Getting a client

Clients are registered by the AIVerID team. To add “Sign in with AIVerID” to your app:

  1. Write to us through the contact form (topic: Developers and integration) with your app's name, its callback URLs and the scopes you need.
  2. Receive your credentials: a client_id, and a client_secret if your app has a server that can keep it.
  3. Test sign-in, sign-out and revocation in your app against the checks above.
  4. Go live.

AI hosts that connect to an MCP server do not need this; see Agent Tokens.

Last updated: October 16, 2026