Base IdP

Base IdP

Frameworks

Express / Node

Verify Base IdP tokens in an Express or plain Node API — verify-only middleware, scope checks, and no required env.

An Express API behind a mobile or SPA frontend is verify-only. It receives a bearer token on each request, verifies it offline against published public keys, and serves the request. It does not run login. It does not call the token endpoint. It does not hold a secret.

This guide shows the full integration in Express, plus the equivalent in plain Node http if you do not use Express.

Scaffold

npx base-idp create --stack express

This writes middleware/baseIdp.ts, a .env (commented placeholder), and a setup readme into ./base-idp-express/.

Install

npm install base-idp

No required env

.env
# Verify-only. Nothing here is required.
# Optional: pin the audience.
# BASE_IDP_CLIENT_ID=sq_live_yourapp

A verify-only API can ship with no Base IdP env at all. Setting BASE_IDP_CLIENT_ID is recommended for multi-tenant environments — it makes the SDK reject tokens minted for a different app.

The middleware

middleware/baseIdp.ts
import type { Request, Response, NextFunction } from "express";
import { BaseIdPServerClient } from "base-idp/server";

const client = new BaseIdPServerClient({
  clientId: process.env.BASE_IDP_CLIENT_ID,
});

export async function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction,
) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";

  if (!token) {
    return res.status(401).json({ error: "missing_token" });
  }

  try {
    (req as any).principal = await client.verifyAccessToken(token);
    next();
  } catch (err) {
    res.status(401).json({ error: "invalid_token" });
  }
}

The pattern is identical to what Passport-style middleware does, except there is no strategy registration step — the SDK handles everything.

Using the middleware

app.ts
import express from "express";
import { requireAuth } from "./middleware/baseIdp";

const app = express();

app.get("/me", requireAuth, (req, res) => {
  res.json((req as any).principal);
});

app.listen(8080, () => console.log("listening on :8080"));

For routes that should be public, omit the middleware. For routes that should be protected, add it.

A scope-checking middleware

The auth middleware proves the user is signed in. A separate middleware can enforce per-route permissions by checking scopes.

middleware/requireScope.ts
import type { Request, Response, NextFunction } from "express";

export function requireScope(...required: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const principal = (req as any).principal;
    const granted: string[] = principal?.scopes ?? [];
    const missing = required.filter((s) => !granted.includes(s));
    if (missing.length) {
      return res.status(403).json({
        error: "insufficient_scope",
        missing,
      });
    }
    next();
  };
}
app.get(
  "/admin/metrics",
  requireAuth,
  requireScope("admin", "metrics:read"),
  handler,
);

A typed request

If you use TypeScript, you can extend Express's Request type so req.principal is properly typed.

types/express.d.ts
import type { VerifiedPrincipal } from "base-idp/server";

declare global {
  namespace Express {
    interface Request {
      principal?: VerifiedPrincipal;
    }
  }
}

After this, drop the (req as any) casts in your handlers.

app.get("/me", requireAuth, (req, res) => {
  res.json(req.principal);
});

Plain Node http

If your service is not Express, the pattern is the same shape with a slightly different signature.

server.ts
import http from "node:http";
import { BaseIdPServerClient } from "base-idp/server";

const client = new BaseIdPServerClient({
  clientId: process.env.BASE_IDP_CLIENT_ID,
});

const server = http.createServer(async (req, res) => {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";

  if (!token) {
    res.writeHead(401, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "missing_token" }));
    return;
  }

  try {
    const principal = await client.verifyAccessToken(token);
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ id: principal.subject, email: principal.email }));
  } catch {
    res.writeHead(401, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "invalid_token" }));
  }
});

server.listen(8080);

A complete verify-only API

app.ts
import express from "express";
import { requireAuth } from "./middleware/baseIdp";
import { requireScope } from "./middleware/requireScope";

const app = express();
app.use(express.json());

// Public routes
app.get("/healthz", (req, res) => res.json({ ok: true }));

// User-only routes
app.get("/me", requireAuth, (req, res) => {
  res.json({
    id: req.principal!.subject,
    email: req.principal!.email,
    scopes: req.principal!.scopes,
  });
});

// Admin-only routes
app.get(
  "/admin/users",
  requireAuth,
  requireScope("admin"),
  async (req, res) => {
    res.json(await db.user.findMany());
  },
);

const port = process.env.PORT ?? 8080;
app.listen(port, () => console.log(`listening on :${port}`));

Two middlewares, three routes, zero Base IdP env required. The whole integration is the import and the requireAuth call.

Common issues

Cannot find module 'base-idp/server'

The SDK's server entry point is for Node only. If you are running this in an edge runtime (Cloudflare Workers, Vercel Edge), use the browser entry point's verifier instead.

Request hangs on first request after a deploy

The SDK fetches the public keys on first use. The first request waits for that fetch. For warm-up, call client.publicKeys(false) at startup.

403 insufficient_scope for routes you expect to pass

The scopes on the token are not what you expect. Decode the token with the CLI to see what scopes it actually carries:

npx base-idp token v4.public.eyJ...

Where to go next

On this page