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.
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.
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
issclaim 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 / 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 parameter | Consent and availability |
|---|---|
scope | Mandatory. If the account lacks an AXUS permission, authorization returns access_denied without a code. |
optional_scope | Available scopes start checked and the user can turn them off. Unavailable permissions are disabled and omitted. |
conditional_scope | Required when the account holds the permission; omitted otherwise. The user cannot turn off a held conditional permission. |
// 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.
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.
// 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.
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.
// 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.
// 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. Resolve the local user. Look up the (issuer, subject) pair, or create a new local user. Enforce uniqueness in your database.
- 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. 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. 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.
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.
