node.jsexpressnestjsnestjs-passportnestjs-jwt

NestJS @Public() decorator works on other routes but returns 401 Unauthorized on one route with @Res() response


I have a NestJS app with global JWT and Roles guards applied in main.ts. I use a custom @Public() decorator to mark public (unauthenticated) routes. The decorator works fine for most routes, but one specific route using @Res() to redirect always returns 401 Unauthorized, even though it's decorated with @Public().

The route decorated with @Public() should bypass authentication and authorization guards and allow public access.

But When I hit this route, I get a 401 Unauthorized response.

main.ts (global guards applied manually)

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import * as dotenv from 'dotenv';
import { RolesGuard } from './common/guards/roles.guard';
import { JwtAuthGuard } from './common/guards/jwt/jwt-auth.guard';

async function bootstrap() {
  dotenv.config();

  const app = await NestFactory.create(AppModule);

  app.enableCors({
    origin: process.env.CLIENT_BASE_URL,
    credentials: true,
    methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
  });

  // Apply global validation pipe
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: false,
    }),
  );

  // Get the Reflector instance for guards that use metadata (e.g. @Roles)
  const reflector = app.get(Reflector);

  // Apply global guards
  app.useGlobalGuards(new JwtAuthGuard(reflector), new RolesGuard(reflector));

  const port = process.env.PORT || 8080;
  await app.listen(port);

  console.log(`🚀 Application is running on: http://localhost:${port}`);
}

void bootstrap();

}

@Public() decorator

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

}

JwtAuthGuard

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

Controller route that fails

@Public()
@Get('stripe-payment-callback')
async membershipStripePaymentCallback(
  @Query('session_id') sessionId: string,
  @Res() res: Response,
) {
  const response = await this.paymentService.stripePaymentCallback(sessionId);
  return res.redirect(response.url);
}

I have other routes decorated with @Public() that do not use @Res(), and those routes work as expected without authorization.

What I tried so far: 1.Tried returning plain data (no @Res()), but still 401 on this route. 2.Confirmed the @Public() metadata is set properly. 3.Added console logs inside JwtAuthGuard to verify isPublic is true for this route. 4.Checked that RolesGuard also respects @Public() (it does). 5.Considered switching to APP_GUARD instead of manual useGlobalGuards.

Why is this route still getting blocked by the auth guard when it is marked as @Public()? Could using @Res() be interfering with guard execution? What are best practices for combining @Public() and @Res()-based routes in NestJS?

Thanks in advance for any suggestions or insights!


Solution

  • I resolved the issue by moving the controller class declaration to the top of the controller file. It seems the previous structure was affecting how the framework recognized the controller. After the change, everything started working as expected.