Developers
Quickstarts

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:

  1. Note your issuer URLhttps://idp.authpi.com/{issuer_id}, or your custom domain.
  2. 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 the authorization_code grant and code response type are enabled.
  3. Copy the client ID and secret. The secret is shown once — store it as an environment variable.
  4. 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