Chapter 4 of 13

Admin Authentication, the Boring Way on Purpose

Before writing the auth module I looked at Better Auth, since it’s had a lot of attention lately and I’d been meaning to try it. I decided against it pretty quickly once I actually thought through what Gatify’s auth needs are. There’s exactly one class of dashboard user (me, the admin), authenticated with email and password against one table. There’s a completely separate authentication concern for the gateway itself, which uses virtual keys, not sessions or OAuth. Better Auth is built around a much richer user/session/account model than either of those cases needs, and pulling it in would have meant adapting my schema to its expectations rather than the other way around. Native @nestjs/jwt plus Passport’s local and JWT strategies do exactly what I need with a fraction of the surface area.

A guard that’s on by default

The first decision that shapes everything downstream: JwtAuthGuard is registered globally, via APP_GUARD, not attached per-controller. Every route in the app requires a valid JWT unless it explicitly opts out.

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

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

    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }
}

The opt-out is a one-line decorator:

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

I like this default a lot more than remembering to add @UseGuards(JwtAuthGuard) to every new controller I write. The failure mode of “forgot to protect a route” is much scarier than “forgot to mark a route public,” because the second one just means a 401 shows up in testing immediately, loud and obvious. This mattered later: when I built the gateway module, its routes needed to bypass this guard entirely (virtual keys aren’t JWTs), so every gateway controller is marked @Public() and then re-protected with a completely separate VirtualKeyGuard. Two independent auth tracks, and the global default meant I couldn’t accidentally leave one of them unprotected by forgetting an annotation.

Login and me

The controller is small on purpose:

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('login')
  @HttpCode(HttpStatus.OK)
  login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
    return this.authService.login(dto.email, dto.password);
  }

  @Get('me')
  @ApiBearerAuth()
  me(@CurrentUser() user: AuthenticatedUser): AuthenticatedUser {
    return user;
  }
}

login is the only public route in the whole admin API surface. me is protected by the global guard, and exists mainly so the frontend can validate a stored token on page load without me building a dedicated “verify token” endpoint.

AuthService.login compares the password with bcrypt and signs a JWT if it matches:

async login(email: string, password: string): Promise<LoginResponseDto> {
  const admin = await this.prisma.adminUser.findUnique({ where: { email } });
  const isValid = admin
    ? await bcrypt.compare(password, admin.passwordHash)
    : false;

  if (!admin || !isValid) {
    throw new UnauthorizedException('Invalid email or password');
  }

  const payload: JwtPayload = { sub: admin.id, email: admin.email };
  const accessToken = await this.jwtService.signAsync(payload);
  const decoded = this.jwtService.decode(accessToken) as { iat: number; exp: number };

  return {
    accessToken,
    tokenType: 'Bearer',
    expiresInSeconds: decoded.exp - decoded.iat,
  };
}

Notice it doesn’t distinguish “no such admin” from “wrong password” in the response. Both collapse into the same UnauthorizedException. That’s deliberate, it’s a small thing but there’s no reason to leak which emails exist in the system.

No sign-up route, on purpose

There is genuinely no endpoint anywhere that creates an AdminUser. I’m the only admin this dashboard will ever need, and every additional endpoint is additional attack surface for zero benefit. Instead, apps/api/prisma/seed.ts upserts exactly one admin from environment variables:

async function main() {
  const email = process.env.ADMIN_EMAIL;
  const password = process.env.ADMIN_PASSWORD;

  if (!email || !password) {
    throw new Error(
      'ADMIN_EMAIL and ADMIN_PASSWORD must be set to seed the initial admin user',
    );
  }

  const passwordHash = await bcrypt.hash(password, 12);

  await prisma.adminUser.upsert({
    where: { email },
    update: {},
    create: { email, passwordHash },
  });
}

Prisma 7 lets you wire the seed command straight into prisma.config.ts (migrations.seed: 'tsx apps/api/prisma/seed.ts'), so prisma db seed just works without a package.json script duplicating the same path. Running this against a real Postgres instance for the first time, logging in with the seeded credentials, and getting back a signed JWT was a genuinely satisfying moment, small as it sounds. It’s the first point in the build where the app did something a browser could actually use.

I tested the failure paths deliberately too: hitting a protected route with no token gives a 401, hitting login with a wrong password gives a 401 with the generic message, and a correct login followed by GET /auth/me with the returned bearer token returns the admin’s id and email. All of that against a real database, not a mock, because auth is exactly the kind of thing where I don’t trust myself to reason about it abstractly.

Next: the crypto module. This is where things get more interesting, because virtual keys and Azure credentials need to be secured completely differently from each other, and I had to be explicit about why.