Authenticate users in a Node.js backend with @authpi/idp — authorization-code flow with PKCE, callback handling, session refresh, and per-organization authorization, step by step.
Last updated 2026-06-13
In this quickstart you’ll add AuthPI login to a server-side TypeScript application with @authpi/idp: redirect users to your hosted login page, exchange the callback code for tokens, keep the session fresh across requests, and make per-organization authorization decisions. Examples use Express, but the SDK is framework-agnostic and also runs on Bun, Deno, and Cloudflare Workers.
Prerequisites:
In the console:
https://idp.authpi.com/{issuer_id}, or your custom domain.http://localhost:3000/callback. Ensure the authorization_code grant and code response type are enabled.npm install @authpi/idp
// auth.ts
import { IdpClient } from '@authpi/idp';
export const idp = new IdpClient({
issuerUrl: process.env.AUTHPI_ISSUER_URL!, // https://idp.authpi.com/{issuer_id}
clientId: process.env.AUTHPI_CLIENT_ID!,
clientSecret: process.env.AUTHPI_CLIENT_SECRET!,
redirectUri: 'http://localhost:3000/callback',
});
createAuthorizationUrl builds the hosted-login URL and generates the PKCE verifier, state, and nonce. Stash them in the user’s session — the callback needs state (CSRF check) and codeVerifier (PKCE) — then redirect:
app.get('/login', async (req, res) => {
const { url, codeVerifier, state, nonce } = await idp.createAuthorizationUrl({
scopes: ['openid', 'profile', 'email'],
});
req.session.oauth = { codeVerifier, state, nonce };
res.redirect(url);
});
The user lands on your issuer’s hosted login page (brandable per issuer), authenticates, and comes back to your callback with a one-time code.
Verify the state matches what you stored, then exchange the code (with the PKCE verifier) for an authenticated agent — the SDK’s handle on the logged-in user. The exchange goes directly to the token endpoint over TLS, and PKCE binds it to the verifier from Step 3:
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (!req.session.oauth || state !== req.session.oauth.state) {
return res.status(400).send('State mismatch');
}
const agent = await idp.exchangeCode(String(code), req.session.oauth.codeVerifier);
req.session.tokens = agent.tokens; // persist tokens; never expose them to the browser
delete req.session.oauth;
res.redirect('/dashboard');
});
On subsequent requests, rebuild the agent from the stored tokens. createAgent refreshes expired access tokens automatically — persist the rotated tokens in onRefresh (refresh tokens are single-use):
async function requireAuth(req, res, next) {
if (!req.session.tokens) return res.redirect('/login');
try {
req.agent = await idp.createAgent(req.session.tokens, {
onRefresh: async (newTokens) => {
req.session.tokens = newTokens;
},
onRefreshError: async () => {
req.session.destroy(() => {});
},
});
next();
} catch {
res.redirect('/login');
}
}
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ user: req.agent.profile });
});
The agent carries the user’s organization memberships from the organizations claim, so authorization checks are local — no extra API call:
app.post('/orgs/:orgId/projects', requireAuth, (req, res) => {
if (!req.agent.hasAccessIn(req.params.orgId, 'write', 'projects')) {
return res.status(403).json({ error: 'insufficient_scope' });
}
// create the project ...
});
Membership changes (added, removed, role changed, organization suspended) are recomputed at every token refresh — see the propagation contract for the exact timing guarantees.
client_credentials for machine-to-machine authagent.tokens