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-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:

  • 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 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.

Step 4: The callback

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');
});

Step 5: Authenticate requests

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 });
});

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