Skip to main content
Developers/Quickstart

Add Sign in with Avonao ID

Register an app in the console, add two routes to your server, and you are reading verified sign-ins. Plan on an hour, most of it spent on mapping members to your own users.

How it works

  1. Your sign-in page has the Sign in with Avonao ID button, a plain link to a route on your server, for example /api/auth/avonao/start.
  2. That route makes a one-time state, nonce and PKCE pair, keeps them in a short-lived cookie, and redirects the member to /authorize at Avonao ID.
  3. The member signs in at Avonao ID. The first time, a consent screen shows your app, your business and its verified domain, and what you asked for.
  4. Avonao ID redirects back to your redirect URI with a code and your state.
  5. Your callback route checks state, exchanges the code at /token with your client secret, and validates the ID token.
  6. You find or create your own user by sub and start your own session. Avonao ID does not run your sessions.

1. Register your app in the console

  1. Open the business console and sign in with your Avonao ID. Create a business: a name, your website, and accept the Business Terms. Invite teammates under Members (roles owner, admin, developer).
  2. Under Apps, choose Create an app and fill in:
    • App name: members see it on the consent screen.
    • Redirect URIs: one per line, up to 10, exact match, no fragments. Use https; http://localhost and http://127.0.0.1 are accepted for an app in development mode only. Example: https://shop.example.com/auth/avonao/callback.
    • Sign-in start URL (the launch URL): the route on your site that starts sign-in, for example https://shop.example.com/api/auth/avonao/start. The button links to it.
    • Privacy Policy URL (needed to go live), and optionally a Terms URL, an icon URL and a description.
  3. Copy the client ID and the client secret. The secret is shown once. Put it in your server's secret store (for example AVONAO_ID_CLIENT_SECRET), never in a browser, a mobile app or a repository.

To rotate the secret without downtime: add a second secret in the console, deploy it, then retire the old one. Both work until you retire the old one. Rotating and deleting ask you to confirm that it is you.

2. The endpoints

Read them from discovery instead of hard-coding them (the document is cached for an hour):

curl
curl -s https://id.avonao.com/.well-known/openid-configuration
curl -s https://id.avonao.com/.well-known/jwks.json
Endpoints
WhatURL
Issuer (the iss claim)https://id.avonao.com
Authorization endpointGET https://id.avonao.com/authorize
Token endpointPOST https://id.avonao.com/token
Userinfo endpointGET https://id.avonao.com/userinfo
JWKS (public signing keys)GET https://id.avonao.com/.well-known/jwks.json
End session endpointGET https://id.avonao.com/logout

3. Send the member to /authorize

Redirect the browser (a plain 302) to https://id.avonao.com/authorize with these query parameters:

Authorization request parameters
ParameterValue
response_typeRequired. code is the only value.
client_idRequired. Your client ID.
redirect_uriRequired. Exactly one of the redirect URIs you registered. Anything else is a plain 400 error page: Avonao ID never redirects to an unregistered address.
scopeSpace separated. Must include openid; add email and profile for the claims you need. Any other scope is refused with invalid_scope.
stateRequired, up to 512 characters. Make it random and single use. It comes back unchanged; compare it before you do anything else.
code_challengeRequired. The unpadded base64url SHA-256 of your PKCE code_verifier (43 characters).
code_challenge_methodRequired. S256 is the only value; plain is refused.
nonceOptional (up to 256 characters) and recommended. It comes back inside the ID token; compare it.
screen_hintsignup opens "Create your Avonao ID" first. Without it, the member sees sign-in.
login_hintOptional, up to 254 characters. A hint for the sign-in form.
promptnone: never show a screen; answer login_required or consent_required instead. login: ask the member to sign in again even if they have a session. consent: show the consent screen again.

The verifier is a random string of 43 to 128 characters from A-Z a-z 0-9 - . _ ~ that never leaves your server until the token request.

4. The redirect back

On success Avonao ID redirects to your redirect URI:

text
https://shop.example.com/auth/avonao/callback?code=wnam_...&state=<your state>

On failure (the member said no, or the request was refused) you get error, sometimes error_description, and your state instead of a code. The codes are in Errors.

  • Check state against the value you stored for this browser first, and drop it after one use.
  • The code is opaque. It works once and for 60 seconds. Using it a second time fails.
  • Show a short message and a retry button on failure. Never retry in a loop.

5. Exchange the code at /token

From your server, send a form-encoded POST with your client credentials (client_secret_basic: HTTP Basic with the client ID and secret, URL-encoded; or client_secret_post: client_id and client_secret in the body). The redirect_uri must be the same string you sent to /authorize.

curl
# exchange the code (valid for 60 seconds, once) for the tokens
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" https://id.avonao.com/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI" \
  -d code_verifier="$VERIFIER"

The answer:

json
{
  "access_token": "wnam_...",
  "token_type": "Bearer",
  "expires_in": 600,
  "id_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij...",
  "scope": "openid email profile"
}
  • id_token lives 5 minutes. access_token lives 10 minutes and is good for /userinfo only. Treat both, and the code, as opaque strings.
  • There is no refresh token. The ID token proves who signed in now: start your own session from it. See Not supported.
  • Errors are JSON, such as { "error": "invalid_grant" }, with status 400, or 401 invalid_client for wrong credentials.

6. Validate the ID token

Do these checks, in this order, on every ID token. A good OIDC library does all of them; if you write it yourself, the Node example below shows each one in about 30 lines of Web Crypto.

  1. It has three dot-separated parts. Read the header: alg must be ES256. Reject anything else, including none.
  2. Find the key whose kid equals the header's kid in the JWKS (https://id.avonao.com/.well-known/jwks.json). Cache the keys for 10 minutes at most, and refetch once when the kid is unknown (keys rotate). Never pin a key.
  3. Verify the signature: ECDSA on P-256 with SHA-256, over header.payload. The signature is the raw 64-byte form that Web Crypto expects.
  4. iss equals https://id.avonao.com and aud equals your client ID.
  5. exp has not passed (allow 30 seconds of clock skew) and iat is not in the future.
  6. nonce equals the one you sent. If you sent none, there is none.

What comes back: claims and scopes

The ID token carries the claims for the scopes you asked for. /userinfo returns the same claims about the member as JSON (without the token-only ones).

ID token payload
{
  "iss": "https://id.avonao.com",
  "aud": "acme-shop-web-k3j9x2ab",
  "iat": 1793398800,
  "exp": 1793399100,
  "auth_time": 1793398790,
  "nonce": "the nonce you sent",
  "amr": ["email"],
  "sid": "q8Zc1f0Wm2k9Xa7B",
  "sub": "avp_mV3k0nT2xqZ8aB1cD2eF3g",
  "email": "mia@example.com",
  "email_verified": true,
  "name": "Mia Example",
  "locale": "en"
}
Claims
ClaimScopeWhat it is
subopenidThe member's id for your business, avp_ plus 22 characters. Stable, unique per member and business. Pairwise ids
iss, aud, iat, expopenidIssuer, your client ID, issued at and expiry (unix seconds). ID token only.
nonceopenidYour nonce, when you sent one. ID token only.
auth_timeopenidUnix seconds of the member's last sign-in or re-authentication at Avonao ID. ID token only.
amropenidHow the member signed in, as a list of strings: email, google, microsoft, github, webauthn (with uv when the passkey verified the person), enterprise, plus totp or recovery_code and mfa after a second factor. ID token only.
sidopenidAn id of the member's Avonao ID session, specific to your business. ID token only.
email, email_verifiedemailThe member's primary email address, and true: Avonao ID only has verified addresses. The member can change it, so key users by sub.
name, localeprofileThe display name (it is null when the member did not set one) and the language, for example en.

Nothing else is sent: no phone number, no birthdate, no picture, no address. Your app never receives the member's Avonao ID account id, and platform_uid is only for Avonao's own platforms. What the identity does and does not tell you is on Verified claims.

Node example

Two files, Node 20 or newer, no dependencies. avonao-id.js is the whole OpenID Connect part. server.js wires the two routes with plain node:http and keeps the flow in a signed, HttpOnly cookie; the same routes work in Express, Fastify, Hono or a Next.js route handler. Set AVONAO_ID_CLIENT_ID, AVONAO_ID_CLIENT_SECRET, AVONAO_ID_REDIRECT_URI and COOKIE_SECRET in your environment.

// avonao-id.js  (Node 20 or newer, ES module, no dependencies)
const ISSUER = process.env.AVONAO_ID_ISSUER ?? "https://id.avonao.com";
const CLIENT_ID = process.env.AVONAO_ID_CLIENT_ID;
const CLIENT_SECRET = process.env.AVONAO_ID_CLIENT_SECRET; // server only: never in a browser or a repository
const REDIRECT_URI = process.env.AVONAO_ID_REDIRECT_URI;   // exactly as registered, e.g. https://shop.example.com/auth/avonao/callback

const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
const rand = (n) => b64u(crypto.getRandomValues(new Uint8Array(n)));

// 1. Build the sign-in request. Keep `flow` (state, nonce, verifier) until the callback; send the member to `url`.
export async function startSignIn({ signup = false } = {}) {
  const verifier = rand(32);
  const challenge = b64u(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
  const flow = { state: rand(24), nonce: rand(24), verifier };
  const url = new URL("/authorize", ISSUER);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: "openid email profile",
    state: flow.state,
    nonce: flow.nonce,
    code_challenge: challenge,
    code_challenge_method: "S256",
    ...(signup ? { screen_hint: "signup" } : {}),
  });
  return { url: url.toString(), flow };
}

// 2. Handle the redirect back: check state, exchange the code, validate the ID token. Returns the claims.
export async function finishSignIn(query, flow) {
  if (!flow || query.get("state") !== flow.state) throw fail("state_mismatch");
  if (query.get("error")) throw fail(query.get("error"), query.get("error_description"));
  const code = query.get("code");
  if (!code) throw fail("invalid_response");

  const res = await fetch(new URL("/token", ISSUER), {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: "Basic " + Buffer.from(encodeURIComponent(CLIENT_ID) + ":" + encodeURIComponent(CLIENT_SECRET)).toString("base64"),
    },
    body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: flow.verifier }),
  });
  const tokens = await res.json().catch(() => ({}));
  if (!res.ok || !tokens.id_token) throw fail(tokens.error ?? "token_exchange_failed");
  return verifyIdToken(tokens.id_token, flow.nonce);
}

// 3. ID token validation with Web Crypto (ES256, keys from the JWKS).
let jwks = { keys: [], at: 0 };
async function keyFor(kid, refresh = false) {
  if (refresh || Date.now() - jwks.at > 10 * 60_000) {
    const r = await fetch(new URL("/.well-known/jwks.json", ISSUER));
    if (!r.ok) throw fail("jwks_unavailable");
    jwks = { keys: (await r.json()).keys, at: Date.now() };
  }
  return jwks.keys.find((k) => k.kid === kid);
}

async function verifyIdToken(idToken, nonce) {
  const [h, p, s] = idToken.split(".");
  if (!h || !p || !s) throw fail("invalid_token");
  const header = JSON.parse(Buffer.from(h, "base64url"));
  if (header.alg !== "ES256") throw fail("invalid_token", "unexpected alg");
  const jwk = (await keyFor(header.kid)) ?? (await keyFor(header.kid, true)); // unknown kid: refetch once (key rotation)
  if (!jwk) throw fail("invalid_token", "unknown kid");
  const key = await crypto.subtle.importKey("jwk", { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
  const signed = new TextEncoder().encode(h + "." + p);
  if (!(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, Buffer.from(s, "base64url"), signed))) throw fail("invalid_token", "bad signature");

  const claims = JSON.parse(Buffer.from(p, "base64url"));
  const now = Date.now() / 1000;
  if (claims.iss !== ISSUER) throw fail("invalid_token", "wrong issuer");
  if (claims.aud !== CLIENT_ID) throw fail("invalid_token", "wrong audience");
  if (claims.exp <= now - 30 || claims.iat > now + 60) throw fail("invalid_token", "expired");
  if (claims.nonce !== nonce) throw fail("invalid_token", "wrong nonce");
  return claims; // { sub, email, email_verified, name, locale, auth_time, amr, sid, ... }
}

function fail(code, description) {
  return Object.assign(new Error(description ?? code), { code });
}

curl: try the endpoints by hand

Make a PKCE pair and the sign-in URL

sh
# a PKCE pair (the verifier stays on your server; the challenge goes in the URL)
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

# the sign-in URL: open it in a browser, sign in, copy the code from the address you land on
echo "https://id.avonao.com/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI\
&scope=openid%20email%20profile&state=$(openssl rand -hex 16)&nonce=$(openssl rand -hex 16)\
&code_challenge=$CHALLENGE&code_challenge_method=S256"

URL-encode redirect_uri if it contains ? or &. Open the printed address in a browser, sign in, and copy the code from the address you land on.

Exchange the code, then ask for the profile

sh
# exchange the code (valid for 60 seconds, once) for the tokens
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" https://id.avonao.com/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI" \
  -d code_verifier="$VERIFIER"
sh
curl -s https://id.avonao.com/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
/userinfo response
{
  "sub": "avp_mV3k0nT2xqZ8aB1cD2eF3g",
  "email": "mia@example.com",
  "email_verified": true,
  "name": "Mia Example",
  "locale": "en"
}

A wrong or expired access token answers 401 with { "error": "invalid_token" } and a WWW-Authenticate header.

Key your users by sub. Do not key them by email. The details, and why, are on Pairwise ids.

pseudocode
// After finishSignIn() returned `claims`. Pseudocode: db is your own data layer.
let user = await db.users.findByAvonaoSub(claims.sub);               // 1. returning member: the pairwise sub is the key
if (!user && claims.email) {                                          // 2. first sign-in with Avonao ID
  const existing = await db.users.findByEmail(claims.email);          //    claims.email is verified by Avonao ID, so you may attach
  if (existing?.emailVerified) {                                      //    it to a local account whose email YOU also verified
    await db.users.update(existing.id, { avonaoSub: claims.sub });
    user = existing;
  }
}
user ??= await db.users.create({ avonaoSub: claims.sub, email: claims.email, name: claims.name }); // 3. new member
await startYourSession(user);                                         // your cookie, your session store

Create your own session only after finishSignIn succeeded. Your session cookie and your rules stay as they are; Avonao ID replaces your sign-up and sign-in forms and your credential handling.

Sign out

Signing out of your app ends only your session, and the member's Avonao ID session continues for their other apps. That is usually what you want. Link "Manage your Avonao ID" to https://id.avonao.com/dashboard.

When the member asks to sign out of Avonao ID too (a shared computer, for example), send the browser to the end session endpoint. post_logout_redirect_uri must be on the same origin as one of your registered redirect URIs, otherwise the member lands on the Avonao ID sign-in page.

end session
# Send the browser here after you ended YOUR session. It ends the Avonao ID session too, then returns to your page.
https://id.avonao.com/logout?client_id=$CLIENT_ID&post_logout_redirect_uri=https%3A%2F%2Fshop.example.com%2Fsigned-out

There is no single logout yet: Avonao ID does not tell your app when the member signs out of Avonao ID.

Test and go live

  1. Develop and test in development mode with your teammates, or add testers (console, app, Testers). Use a second app in development mode for localhost redirect URIs: a live app refuses them.
  2. Verify your domain (console, Domains). Publish the token either as a DNS TXT record at _avonao-id.<domain> with the value avonao-id-verification=<token>, or as a file at https://<domain>/.well-known/avonao-id.txt containing that line (served directly, without a redirect), then press Verify. A verified domain covers its subdomains, and only one business can verify a domain.
  3. Go live once every redirect URI and the sign-in start URL are https on a verified domain and the app has a Privacy Policy URL. The app is live at once, and Avonao reviews new live apps afterwards. Your business then shows in the Avonao Business Circle directory if you chose to be listed. Pause moves the app back to development mode.
  4. Run through the Security checklist and test with a member from outside your business.

Not supported

These are not in Avonao ID today, so do not build on them: refresh tokens (the token endpoint only knows authorization_code), the implicit and hybrid flows, public clients (every app has a secret, so single-page and mobile apps need a small server of their own for the token exchange), token revocation and introspection endpoints, dynamic client registration, and single logout.

One account
Avonao ID

One Avonao ID. Every platform. Your call.

Create your account in a minute with a passkey, then connect the platforms you use.

Avonao ID account