Server-side auth with the TypeScript SDK
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-09-03
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:
- An AuthPI account with an issuer (sign up here)
- Node.js 18+
Step 1: Configure AuthPI resources
In the console:
- Note your issuer URL —
https://idp.authpi.com/{issuer_id}, or your custom domain. - Register a client with application type “Web” (confidential). Add a redirect URI that exactly matches your callback route — for local development,
http://localhost:3000/callback. Ensure theauthorization_codegrant andcoderesponse type are enabled. - Copy the client ID and secret. The secret is shown once — store it as an environment variable.
- Create a test user in the issuer so you have someone to log in as.
Step 2: Install and initialize
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',
});
Step 3: The login route
createAuthorizationUrl builds the hosted-login URL and generates the PKCE verifier, state, and nonce. Stash the returned object in the user’s session; the callback helper uses it for the CSRF, PKCE, and nonce checks.
app.get('/login', async (req, res) => {
const auth = await idp.createAuthorizationUrl({
scopes: ['openid', 'profile', 'email'],
});
req.session.oauth = auth;
res.redirect(auth.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.
Step 4: The callback
Validate the callback, then exchange the code for an authenticated agent — the SDK’s handle on the logged-in user. exchangeCallback checks state, uses the stored PKCE verifier, exchanges the code, requires an ID token, and verifies its nonce:
app.get('/callback', async (req, res) => {
if (!req.session.oauth) return res.status(400).send('Missing OAuth session');
const callbackUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const agent = await idp.exchangeCallback(callbackUrl, req.session.oauth);
req.session.tokens = agent.tokens; // persist tokens; never expose them to the browser
delete req.session.oauth;
res.redirect('/dashboard');
});
Step 5: Authenticate requests
On subsequent requests, rebuild the agent from the stored tokens. createAgent refreshes expired access tokens automatically. Its onRefresh hook runs immediately after the successful token response is parsed and before optional user-info hydration, so persist the rotated tokens there (refresh tokens are single-use). If hydration fails afterward, the SDK preserves the new token state for a later createAgent retry and does not call onRefreshError:
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 () => {
// This runs only when fresh tokens could not be obtained or persisted.
req.session.destroy(() => {});
},
});
next();
} catch {
res.redirect('/login');
}
}
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ user: req.agent.profile });
});
Step 6: Authorize with organizations
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.
Where to go next
- IdP SDK — TypeScript — the full client reference, including
client_credentialsfor machine-to-machine auth - Token claims reference — everything inside
agent.tokens - Manage and revoke sessions — listing sessions and logging users out everywhere
- Validate tokens in your API — verifying these tokens in your other services