Skip to content
AXUS IDAXUS ID
Docs/Add sign-in
On this page

Quickstart · Authorization Code + PKCE

From button
to signed-in user.

The full path, with each responsibility made explicit. AXUS ID authenticates the user. Your app verifies the response and creates its own session.

Server-side TypeScriptNode.js + joseNo AXUS SDK
Before you begin. You need a backend and a session store. The snippets below form one authentication helper; you must connect it to your routes, database and session library. Using another stack? The HTTP flow is the same. Generate an AI brief tailored to your app, or configure your existing OIDC library with discovery and public-client authentication.

01 / Configure

Register the return address.

Open Developer settings, copy your account’s AUID, and register your app’s callback URL. This AUID becomes client_id. Register development and production URLs separately; the protocol, host, port, path and trailing slash must match.

For changing preview URLs, open Developer testing on your signing-in account and enable unregistered redirect URIs for your app’s AUID. This exception applies only to that account. Every unregistered callback requires a three-second security warning and access review; silent sign-in is blocked. Keep using the same client ID and exact redirect URI in the token exchange.

Client ID · your AUID
.env.local · in your app
AXUS_ISSUER="https://axusid-website.vercel.app"
AXUS_CLIENT_ID="YOUR_AUID"
AXUS_REDIRECT_URI="http://localhost:3000/api/auth/axus/callback"

If your app already uses port 3000, use its actual callback port. The issuer points to AXUS ID; the redirect URI points to your app. Use HTTPS in production.

What are issuer, client ID and redirect URI?
Issuer
The identity provider you trust. It must match the ID token’s iss claim exactly.
Client ID
The public identifier for your app’s authorization configuration. It is not a password.
Redirect URI
Your backend route that receives the user after sign-in. Registering it prevents codes being sent to an arbitrary destination.

02 / Send the user to AXUS ID

Create a fresh sign-in transaction.

Install jose in your app (npm install jose). Add the following helper on your server. Each attempt gets a random state, nonce and verifier. The SHA-256 challenge can go in the URL; the verifier stays on your server.

lib/axus-auth.ts · part 1 of 3
// lib/axus-auth.ts — server only; install jose in your app
import { randomBytes, createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

function env(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}
const issuer = env("AXUS_ISSUER");
const clientId = env("AXUS_CLIENT_ID");
const redirectUri = env("AXUS_REDIRECT_URI");
const jwks = createRemoteJWKSet(
  new URL(issuer + "/.well-known/jwks.json")
);

export type Transaction = {
  state: string;
  nonce: string;
  verifier: string;
  createdAt: number;
  requiredScopes: string[];
};

export function beginSignIn(permissions: {
  required?: string[];
  optional?: string[];
  conditional?: string[];
} = {}) {
  const random = () => randomBytes(32).toString("base64url");
  const transaction: Transaction = {
    state: random(), nonce: random(), verifier: random(),
    createdAt: Date.now(),
    requiredScopes: ["openid", "profile", ...(permissions.required ?? [])],
  };
  const challenge = createHash("sha256")
    .update(transaction.verifier).digest("base64url");
  const url = new URL(issuer + "/authorize");
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: transaction.requiredScopes.join(" "),
    ...(permissions.optional?.length
      ? { optional_scope: permissions.optional.join(" ") } : {}),
    ...(permissions.conditional?.length
      ? { conditional_scope: permissions.conditional.join(" ") } : {}),
    state: transaction.state,
    nonce: transaction.nonce,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString();
  return { url: url.toString(), transaction };
}
Wire your login route: call beginSignIn(), store the transaction with a ten-minute expiry, bound to the initiating browser session and state, then redirect to the returned URL. Use an opaque HttpOnly, SameSite=Lax cookie to identify that session, with Secure in production. Never put the transaction in a shared global variable.

The same redirect in your stack

These start the flow only. The exchange, verification, and session below stay server-side regardless of stack.

Next.js
// app/api/auth/axus/login/route.ts — exchange stays server-side (step 03)
import { randomBytes, createHash } from "node:crypto";
import { NextResponse } from "next/server";

const ISSUER = "https://axusid-website.vercel.app";
const b64url = (b: Buffer) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

export async function GET() {
  const verifier = b64url(randomBytes(32));
  const challenge = b64url(createHash("sha256").update(verifier).digest());
  const state = b64url(randomBytes(16));
  const q = new URLSearchParams({ response_type: "code", client_id: process.env.AXUS_CLIENT_ID!, redirect_uri: process.env.AXUS_REDIRECT_URI!, scope: "openid profile", state, code_challenge: challenge, code_challenge_method: "S256" });
  const res = NextResponse.redirect(ISSUER + "/authorize?" + q);
  const opts = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const, path: "/", maxAge: 600 };
  res.cookies.set("axus_verifier", verifier, opts);
  res.cookies.set("axus_state", state, opts);
  return res;
}

Point the “Continue with AXUS ID” button to that login route. Use the button and brand assets to match your existing sign-in options.

White or black on light surfaces — same size as neighboring buttons

Continue with AXUS IDContinue with AXUS ID

Try it live

Configure a request and press the real button into the flow. Locally, the seeded axusid-dev client accepts http://localhost:3000/callback after npm run db:seed.

01 Your app

Client ID · your AUID

Valid URL — if it isn’t saved yet, register it below.

02 Scopes

Selected identity scopes go in mandatory scope. Use the fields below to choose how API access and optional identity data are requested.

Separate scopes with spaces and put each scope in one list only. Replace the example app AUID with the permission’s declaration owner. Availability is checked during authorization.

03 Fresh values

generating…

Live request

Generating fresh values…

Nothing here is sent anywhere until you press the button.

Why three random values?

State binds the callback to the browser that started sign-in. PKCE binds the authorization code to the app holding the verifier. Nonce binds the ID token to this particular sign-in attempt. They do different jobs; keep all three.

02 / Choose the access your app needs

Request required, optional and conditional permissions.

For sign-in alone, call beginSignIn() to request openid profile. For AXUS API access, pass declared permission keys in the three lists below. The helper keeps the identity scopes mandatory and adds your API permissions.

Request parameterConsent and availability
scopeMandatory. If the account lacks an AXUS permission, authorization returns access_denied without a code.
optional_scopeAvailable scopes start checked and the user can turn them off. Unavailable permissions are disabled and omitted.
conditional_scopeRequired when the account holds the permission; omitted otherwise. The user cannot turn off a held conditional permission.
Request API permissions with the sign-in helper
// Replace 5 with the declaration owner's AUID and use declared keys.
// Store the returned transaction before redirecting, just as for sign-in alone.
const { url, transaction } = beginSignIn({
  required: ["app:5:posts.read"],
  optional: ["app:5:posts.write"],
  conditional: ["app:5:posts.moderate"],
});

Each parameter is space-separated. A scope must appear in only one list. Optional scopes can include OIDC scopes such as email; conditional scopes support AXUS permissions only. optional_scope and conditional_scope are AXUS ID extensions. For an OIDC library, pass them as additional authorization parameters.

Unprefixed keys use the AXUS ID system context. Use app:<owner AUID>:<permission key>for app permissions; the context identifies the declaration owner, independently of your client ID. See the permission guidefor declarations and wildcard rules.

Consent follows the selected account. AXUS ID checks effective access after account selection and again when the user submits consent. Check failures stop authorization. Only the approved scopes reach the tokens. Declined optional scopes prompt again if requested later; prompt=none returns consent_requiredwhen approval is needed. Use prompt=consent to review existing choices.

03 / Handle the return

Consume the transaction. Exchange the code.

On your registered callback route, load and atomically delete the transaction belonging to this browser and the returned state. Missing transaction? Start over. Then pass it and the callback URL to exchangeCode(). Handle thrown errors with a friendly retry page; do not create a session on failure.

lib/axus-auth.ts · part 2 of 3
// Continue in lib/axus-auth.ts
export type TokenSet = {
  idToken: string;
  accessToken: string;
  scopes: Set<string>;
};

export async function exchangeCode(
  callback: URL,
  transaction: Transaction | undefined,
) {
  // The route must atomically consume the browser-bound transaction
  // from your server store BEFORE calling this function.
  const state = callback.searchParams.get("state");
  if (!transaction || !state || state !== transaction.state ||
      Date.now() - transaction.createdAt > 10 * 60 * 1000) {
    throw new Error("Invalid or expired sign-in. Start again.");
  }
  if (callback.searchParams.has("error")) {
    // Don't render raw provider error text or log the callback URL.
    if (callback.searchParams.get("error") === "access_denied") {
      throw new Error("Access was declined or this account lacks required permissions.");
    }
    if (callback.searchParams.get("error") === "consent_required") {
      throw new Error("Start an interactive sign-in to review permissions.");
    }
    throw new Error("Sign-in was not completed. Please try again.");
  }
  const code = callback.searchParams.get("code");
  if (!code) throw new Error("Missing authorization code.");

  const response = await fetch(issuer + "/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: clientId,
      redirect_uri: redirectUri,
      code,
      code_verifier: transaction.verifier,
    }),
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) throw new Error("Code exchange failed. Start again.");
  const tokens = await response.json();
  if (typeof tokens.id_token !== "string" ||
      typeof tokens.access_token !== "string" ||
      typeof tokens.scope !== "string" ||
      tokens.token_type !== "Bearer") {
    throw new Error("Unexpected token response.");
  }
  const scopes = new Set<string>(tokens.scope.split(/\s+/).filter(Boolean));
  if (transaction.requiredScopes.some((scope) => !scopes.has(scope))) {
    throw new Error("A required scope was not granted.");
  }
  return {
    idToken: tokens.id_token as string,
    accessToken: tokens.access_token as string,
    scopes,
  };
}

Codes expire after five minutes and are single-use. A failed exchange can consume the code too, so start a new sign-in instead of repeatedly submitting the old code. Clear the transaction on cancellation and errors as well as success.

access_denied can mean the user cancelled or their account is missing mandatory permissions. Validate the returned state before handling that error; do not exchange a code or create a local session. Their AXUS ID session remains active. Offer a fresh sign-in with a suitable account or revise the request when that access is optional for your app.

Browser + backend boundary: send token and userinfo requests from your backend. Browser CORS support is not advertised by these endpoints. A frontend-only button snippet is not a complete authentication implementation.

04 / Trust, then use

Verify the identity, not just the JSON.

Verify the ID token’s signature, issuer, audience, expiry and nonce before trusting its subject. The example also reads userinfo and checks that both responses identify the same person. Decoding a JWT by itself does not verify it.

lib/axus-auth.ts · part 3 of 3
// Continue in lib/axus-auth.ts
export async function verifyIdentity(
  tokens: TokenSet,
  transaction: Transaction,
) {
  const { payload } = await jwtVerify(tokens.idToken, jwks, {
    issuer,
    audience: clientId,
    algorithms: ["RS256"],
    requiredClaims: ["iss", "aud", "exp", "iat", "sub", "nonce"],
  });
  if (typeof payload.sub !== "string" || !payload.sub ||
      payload.nonce !== transaction.nonce) {
    throw new Error("Invalid identity or nonce.");
  }
  const response = await fetch(issuer + "/oauth/userinfo", {
    headers: { Authorization: `Bearer ${tokens.accessToken}` },
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) throw new Error("Could not load the profile.");
  const profile = await response.json();
  if (profile.sub !== payload.sub) {
    throw new Error("Userinfo subject does not match the ID token.");
  }
  return {
    issuer,
    subject: payload.sub,
    scopes: [...tokens.scopes],
    name: typeof profile.name === "string" ? profile.name : undefined,
    username: typeof profile.preferred_username === "string"
      ? profile.preferred_username : undefined,
  };
}

The sub claim is the user’s AUID. Name and username can be absent. The default example requests openid profile; add API permissions with the helper’s required, optional and conditional lists.

Enable features from approved scopes.

The token response’s scope contains the full approved OIDC and AXUS scope set. The exchange helper preserves it and checks the mandatory scopes saved with the transaction. After verifying identity, use that approved set to decide which optional or conditional features to show. A requested scope alone is not proof of approval.

Use approved scopes after verification
// In your backend callback, after consuming the transaction:
const tokens = await exchangeCode(callback, transaction);
const identity = await verifyIdentity(tokens, transaction);
const granted = new Set(identity.scopes);

const features = {
  canWritePosts: granted.has("app:5:posts.write"),
  canModeratePosts: granted.has("app:5:posts.moderate"),
};
// Keep the approved scope set with your server session, if these features need it.
// Your API must still enforce permissions on every protected operation.

Check the returned scope set again after refresh. Removing a permission through consent replaces a broader native app token; handle subsequent API denials by updating the feature or asking the user to review access.

05 / Finish in your app

Create your session.

Call verifyIdentity(tokens, transaction) after the exchange. Use the returned issuer and subject as a unique external identity in your database. Then create or rotate a session using your app’s existing session library.

  1. 1. Resolve the local user. Look up the (issuer, subject) pair, or create a new local user. Enforce uniqueness in your database.
  2. 2. Start a new session. Store session data on your server. Return only an opaque session cookie: HttpOnly, SameSite=Lax, Secure in production, with an explicit lifetime.
  3. 3. Finish the redirect. Redirect to a fixed safe page in your app. Keep codes and tokens out of URLs, analytics, logs and browser storage.
  4. 4. Keep only what you need. For sign-in alone, you do not need a refresh token. Store provider tokens securely on the server only if your app will call AXUS APIs later.
Account linking is a separate action. Never merge local accounts using the synthetic email claim, a name or username. Require an authenticated user and explicit confirmation to connect AXUS ID to an existing account.

Before you call it done

Exercise these paths in your own app. A successful token exchange alone is not a completed integration.

  • A new user can sign in and gets a local session.
  • A returning user reaches the same local account.
  • Denied consent offers a safe way to try again.
  • Missing mandatory access never creates a local session.
  • Declined optional scopes disable only their features.
  • Conditional access is required when held and omitted when absent.
  • Missing or mismatched state never creates a session.
  • Expired or replayed codes require a fresh sign-in.
  • Invalid signatures, nonce or subject mismatches fail closed.
  • Two tabs keep separate sign-in transactions.
  • Local logout destroys your app’s session.