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_secretThis 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-idpConfigure
Confidential (server-rendered login)
BASE_IDP_CLIENT_ID=sq_live_api
BASE_IDP_CLIENT_SECRET=sqk_your_secretVerify-only (API behind a frontend)
# Optional — pins the audience.
# BASE_IDP_CLIENT_ID=sq_live_apiNothing else is required for verification.
The guard
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
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.
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.
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;
}
}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.
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
import { Module } from "@nestjs/common";
import { UsersController } from "./users/users.controller";
import { AdminController } from "./admin/admin.controller";
@Module({
controllers: [UsersController, AdminController],
})
export class AppModule {}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.