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/revokeis the one your session actually stores, notundefined. -
Sign-out must be POST-only with an origin check.
A logout route that accepts GET is a CSRF primitive: with
SameSite=Nonecookies (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) returnsilently 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.xyzis 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_useis"agent"grant_idis presentaudequals 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:
httpsonly — 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
audis your resource can carry only theyourapp:*scopes registered for that surface (plusprofileandemailwhere the client holds them). A request that names your resource and asks for another service's scopes is refused withinvalid_scopebefore 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
tokensandbusinessare 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.