Base IdP

Base IdP

Frameworks

NestJS

Protect NestJS routes with a Base IdP guard. Both verify-only and confidential server shapes covered.

NestJS apps come in two shapes when it comes to identity: a server-rendered web app that runs the login (confidential), and an API behind a separate frontend (verify-only). This guide covers both with the same guard pattern; only the configuration differs.

Scaffold

npx base-idp create \
  --stack nestjs \
  --client-id sq_live_api \
  --client-secret sqk_your_secret

This writes src/auth/base-idp.guard.ts and a .env into ./base-idp-nestjs/. Move them into your project.

If your NestJS app is only verifying tokens (no server-side login), skip the --client-secret flag; the guard still works with just a client id.

Install

npm install base-idp

Configure

Confidential (server-rendered login)

.env
BASE_IDP_CLIENT_ID=sq_live_api
BASE_IDP_CLIENT_SECRET=sqk_your_secret

Verify-only (API behind a frontend)

.env
# Optional — pins the audience.
# BASE_IDP_CLIENT_ID=sq_live_api

Nothing else is required for verification.

The guard

src/auth/base-idp.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { BaseIdPServerClient } from "base-idp/server";

const client = new BaseIdPServerClient({
  clientId: process.env.BASE_IDP_CLIENT_ID,
  secret: process.env.BASE_IDP_CLIENT_SECRET, // ignored for verify-only
});

@Injectable()
export class BaseIdpGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest();
    const header: string = req.headers["authorization"] ?? "";
    const token = header.startsWith("Bearer ") ? header.slice(7) : "";
    if (!token) throw new UnauthorizedException("missing bearer token");

    try {
      req.principal = await client.verifyAccessToken(token);
      return true;
    } catch (err) {
      throw new UnauthorizedException("invalid token");
    }
  }
}

The guard reads the bearer token, verifies it, attaches the principal to the request, and lets the request through.

Protecting routes

src/users/users.controller.ts
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
import { BaseIdpGuard } from "../auth/base-idp.guard";

@Controller("users")
@UseGuards(BaseIdpGuard)
export class UsersController {
  @Get("me")
  me(@Req() req) {
    return req.principal;
  }
}

Apply the guard at the controller level for "all routes here require auth," or at the method level for individual routes.

A custom decorator for the principal

Hand-rolling req.principal access in every handler is repetitive. A custom decorator cleans it up.

src/auth/principal.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";

export const Principal = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return req.principal;
  },
);
@Get("me")
me(@Principal() principal) {
  return principal;
}

Scope-based authorization

The guard handles the "is this token valid" check. For "does this user have permission to do X," use a separate guard that reads scopes off the principal.

src/auth/scopes.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";

@Injectable()
export class ScopesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.get<string[]>("scopes", context.getHandler()) ?? [];
    if (required.length === 0) return true;

    const req = context.switchToHttp().getRequest();
    const granted: string[] = req.principal?.scopes ?? [];

    const missing = required.filter((s) => !granted.includes(s));
    if (missing.length) {
      throw new ForbiddenException(`missing scopes: ${missing.join(", ")}`);
    }
    return true;
  }
}
src/auth/scopes.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const Scopes = (...scopes: string[]) => SetMetadata("scopes", scopes);

Use both guards together:

@Controller("admin")
@UseGuards(BaseIdpGuard, ScopesGuard)
export class AdminController {
  @Get("metrics")
  @Scopes("admin", "metrics:read")
  metrics() {
    return { uptime: process.uptime() };
  }
}

A login flow for confidential apps

If the NestJS app is a server-rendered web app and runs the login itself, add login and callback controllers.

src/auth/auth.controller.ts
import { Controller, Get, Query, Res } from "@nestjs/common";
import type { Response } from "express";
import { BaseIdPServerClient } from "base-idp/server";

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

@Controller("auth")
export class AuthController {
  @Get("login")
  async login(@Query("return_to") returnTo: string, @Res() res: Response) {
    await client.resolveConfig();
    res.redirect(client.authorizeUrl({ state: returnTo ?? "/" }));
  }

  @Get("callback")
  async callback(
    @Query("code") code: string,
    @Query("state") state: string,
    @Res() res: Response,
  ) {
    const tokens = await client.exchangeCode({ code });
    const principal = await client.verifyAccessToken(tokens.access_token);

    // Mint your own session cookie here.
    res.cookie("session", await mintSession(principal, tokens), {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
    });

    res.redirect(state || "/");
  }
}

A complete protected API

src/app.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users/users.controller";
import { AdminController } from "./admin/admin.controller";

@Module({
  controllers: [UsersController, AdminController],
})
export class AppModule {}
src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Three files: guard, controller, main. The guard is shared across every controller you mark with @UseGuards.

Common issues

Unauthorized on every request

Either the Authorization header is missing or wrongly formatted. The header must be Authorization: Bearer <token>, with one space after Bearer and no quoting.

Cannot read properties of undefined (reading 'subject')

You used req.principal inside a route that does not have the guard applied. Either add @UseGuards(BaseIdpGuard) to the controller, or default to req.principal?.subject.

"Invalid signature" during local dev

You have a mismatched BASE_IDP_ISSUER between your client and your backend. Both must point at the same issuer.

Where to go next

On this page