Skip to content
KheAi
Go back

Authentication, Guards & Security Patterns

Edit page

What This Part Covers


Meteor Equivalents

MeteorNestJSDifference
accounts-base + accounts-passwordPassportModule + JwtModule + bcryptExplicit implementation, auditable
Meteor.userId()@CurrentUser() user: AccessTokenUserInjected from verified JWT
Meteor.user()currentUser.user (the UserEntity from DB)
if (!this.userId) throw new Meteor.Error()@UseGuards(AuthJwtGuard)Declarative guard, applied at class/method level
DDP session token (in localStorage)JWT accessToken (RS256)Stateless, cryptographically verifiable
Galaxy loginPOST /graphql with Authorization: Bearer <token>Standard HTTP auth

1. HS256 vs RS256: The Key Difference

All JWTs can use symmetric or asymmetric signing.

HS256 (HMAC-SHA256) — Symmetric

One secret key: used to BOTH sign AND verify

Problem: every service that needs to verify tokens must have the secret. Any service that can verify can also forge tokens.

RS256 (RSA-SHA256) — Asymmetric

Private key: only the auth service has it — used to SIGN tokens
Public key: every service can have it — used to VERIFY tokens

Benefits:

  1. Compartmentalisation. A compromised downstream service cannot forge JWTs — it only has the public key.
  2. Independent rotation. Rotate the user access key without affecting the admin portal key.
  3. Multiple key pairs. This codebase uses three: JWT (user access), JWT_REFRESH (user refresh), ADMIN_JWT (admin portal). A stolen user token cannot be replayed against admin endpoints — different key pair, signature verification fails.

Master key vs wax seal: HS256 is a master key — whoever has it can both lock (sign) and unlock (verify). RS256 is a royal wax seal: only the king has the signet ring (private key) that makes the seal. Anyone can inspect a seal to verify it’s genuine (public key). But no one can produce a convincing forgery — the signet ring never leaves the king’s possession. In a multi-service architecture, downstream services verify tokens but can never issue them.

Generating RSA Key Pairs

Run once locally. Generate fresh keys for each environment (dev, staging, production).

# User JWT key pair (4096-bit for security)
openssl genrsa -out jwt_private.pem 4096
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem

# Refresh token key pair
openssl genrsa -out jwt_refresh_private.pem 4096
openssl rsa -in jwt_refresh_private.pem -pubout -out jwt_refresh_public.pem

Convert to single-line format for .env:

# macOS/Linux: add \n line escaping
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_private.pem

Paste the output into .env:

JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----\n"
JWT_REFRESH_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
JWT_REFRESH_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..."

Production: Never store private keys in .env files committed to a repository. Use AWS Secrets Manager or Tencent SSM. The ECS task definition loads secrets at runtime from Secrets Manager — the key never touches disk or source control.


2. User Entity

The UserEntity is the foundation of authentication.

// apps/api/src/modules/user/user.entity.ts
import { Column, Entity, Index } from 'typeorm';
import { AbstractEntity } from 'nestjs-dev-utilities';
import { UserStatus } from './user.constant';

@Entity({ name: 'user' })
export class UserEntity extends AbstractEntity {
  @Column()
  fullname: string;

  @Index()  // indexed because we look up users by username often
  @Column({ unique: true })
  username: string;

  @Index()  // indexed because we look up users by email often
  @Column({ unique: true })
  email: string;

  // NEVER expose this field in any DTO — it is never sent to clients
  @Column()
  password: string;

  @Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE })
  status: UserStatus;

  // Optional: for 2FA (Part 11)
  @Column({ nullable: true })
  twoFactorSecret: string | null;
}
// apps/api/src/modules/user/user.constant.ts
import { registerEnumType } from '@nestjs/graphql';

export enum UserStatus {
  ACTIVE = 'ACTIVE',
  INACTIVE = 'INACTIVE',
  SUSPENDED = 'SUSPENDED',
}
registerEnumType(UserStatus, { name: 'UserStatus' });

3. Auth DTOs

// apps/api/src/modules/auth/dto/auth.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator';

@InputType()
export class RegisterInput {
  @Field()
  @IsString()
  @IsNotEmpty()
  fullname: string;

  @Field()
  @IsString()
  @IsNotEmpty()
  @Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username can only contain letters, numbers, and underscores' })
  username: string;

  @Field()
  @IsEmail()
  email: string;

  @Field()
  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  @Matches(/(?=.*[A-Z])(?=.*[0-9])/, {
    message: 'Password must contain at least one uppercase letter and one number',
  })
  password: string;
}

@InputType()
export class SignInInput {
  @Field()
  @IsString()
  @IsNotEmpty()
  username: string;  // username or email

  @Field()
  @IsString()
  @IsNotEmpty()
  password: string;
}

@InputType()
export class RefreshTokenInput {
  @Field()
  @IsString()
  @IsNotEmpty()
  refreshToken: string;
}
// apps/api/src/modules/auth/dto/auth.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class AuthTokensDto {
  @Field()
  accessToken: string;

  @Field()
  refreshToken: string;
}

4. Auth Interface

Define the shape of the decoded JWT payload:

// apps/api/src/modules/auth/auth.interface.ts
import { UserEntity } from '../user/user.entity';

export interface JwtPayload {
  sub: number;     // user id (standard JWT claim)
  username: string;
  platform: 'user' | 'portal'; // required — RequestPlatformInterceptor rejects mismatches
  iat: number;     // issued at
  exp: number;     // expires at
}

export interface AccessTokenUser {
  user: UserEntity;
}

5. Auth Service

// apps/api/src/modules/auth/auth.service.ts
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';

import { UserEntity } from '../user/user.entity';
import { RegisterInput, SignInInput } from './dto/auth.input';
import { AuthTokensDto } from './dto/auth.dto';
import { JwtPayload } from './auth.interface';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
    private readonly jwtService: JwtService,
    private readonly config: ConfigService,
  ) {}

  async register(input: RegisterInput): Promise<AuthTokensDto> {
    // Check uniqueness
    const existingByUsername = await this.userRepo.findOne({ where: { username: input.username } });
    if (existingByUsername) throw new BadRequestException('Username already taken');

    const existingByEmail = await this.userRepo.findOne({ where: { email: input.email } });
    if (existingByEmail) throw new BadRequestException('Email already registered');

    // Hash password — never store plain text
    const hashedPassword = await bcrypt.hash(input.password, 12);

    const user = this.userRepo.create({ ...input, password: hashedPassword });
    const savedUser = await this.userRepo.save(user);

    return this.generateTokens(savedUser);
  }

  async signIn(input: SignInInput): Promise<AuthTokensDto> {
    // Find by username or email
    const user = await this.userRepo.findOne({
      where: [{ username: input.username }, { email: input.username }],
    });

    if (!user) throw new UnauthorizedException('Invalid credentials');

    // Compare password against stored hash
    const passwordMatch = await bcrypt.compare(input.password, user.password);
    if (!passwordMatch) throw new UnauthorizedException('Invalid credentials');

    if (user.status !== 'ACTIVE') throw new UnauthorizedException('Account is not active');

    return this.generateTokens(user);
  }

  async refreshToken(token: string): Promise<AuthTokensDto> {
    let payload: JwtPayload;
    try {
      // Verify with the REFRESH public key (different from access token key)
      payload = this.jwtService.verify(token, {
        publicKey: this.config.get('JWT_REFRESH_PUBLIC_KEY')?.replace(/\\n/g, '\n'),
        algorithms: ['RS256'],
      });
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }

    const user = await this.userRepo.findOne({ where: { id: payload.sub } });
    if (!user) throw new UnauthorizedException('User not found');

    return this.generateTokens(user);
  }

  private generateTokens(user: UserEntity): AuthTokensDto {
    const payload: Partial<JwtPayload> = { sub: user.id, username: user.username, platform: 'user' };

    const accessToken = this.jwtService.sign(payload, {
      privateKey: this.config.get('JWT_PRIVATE_KEY')?.replace(/\\n/g, '\n'),
      algorithm: 'RS256',
      expiresIn: this.config.get('JWT_EXPIRATION_TIME') ?? '1d',
    });

    const refreshToken = this.jwtService.sign(payload, {
      privateKey: this.config.get('JWT_REFRESH_PRIVATE_KEY')?.replace(/\\n/g, '\n'),
      algorithm: 'RS256',
      expiresIn: this.config.get('JWT_REFRESH_EXPIRATION_TIME') ?? '7d',
    });

    return { accessToken, refreshToken };
  }
}

The specialist doctor: The AuthService is the specialist doctor in the Citadel hospital. It examines the credentials (verifies password, checks account status), applies the rules (bcrypt rounds, uniqueness checks), and prescribes the result (tokens). It never answers the front desk phone — that is the AuthResolver’s job. All security decisions live here, not in the resolver.

From Meteor? In Meteor, Accounts.createUser() and Accounts.loginWithPassword() were black-box framework calls — you got auth “for free” but had no visibility into what they did. NestJS AuthService is explicit: you see every step, every hashing round, every token generation. When a security audit asks “how do you hash passwords?”, you open auth.service.ts and show them.

Memory hook: AuthService = specialist doctor. Hashes, verifies, generates tokens. All credential logic lives here, never in the resolver.

Key security decisions:

  1. bcrypt.hash(password, 12) — bcrypt with 12 rounds. Higher rounds = harder to brute-force. 12 is the production standard.
  2. UnauthorizedException('Invalid credentials') — the same error message for both “user not found” and “wrong password”. Never reveal which one failed (timing attack mitigation).
  3. Refresh token uses a different private key than the access token. A stolen access token cannot generate new access tokens.
  4. replace(/\\n/g, '\n') — PEM keys in .env have \n as literal backslash-n. This converts them back to real newlines.

6. JWT Strategy (Passport)

The Passport strategy validates incoming requests. It runs automatically when AuthJwtGuard is applied to a resolver.

// apps/api/src/modules/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';

import { UserEntity } from '../../user/user.entity';
import { JwtPayload, AccessTokenUser } from '../auth.interface';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly config: ConfigService,
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
  ) {
    super({
      // Extract JWT from Authorization: Bearer <token> header
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // Use the public key to VERIFY (not the private key)
      secretOrKey: config.get<string>('JWT_PUBLIC_KEY')?.replace(/\\n/g, '\n'),
      algorithms: ['RS256'],
    });
  }

  // Called after the JWT signature is verified
  // Whatever this returns is attached to req.user (and accessible via @CurrentUser())
  async validate(payload: JwtPayload): Promise<AccessTokenUser> {
    const user = await this.userRepo.findOne({
      where: { id: payload.sub },
    });

    if (!user) throw new UnauthorizedException('User not found or token revoked');
    if (user.status !== 'ACTIVE') throw new UnauthorizedException('Account is not active');

    return { user };
  }
}

Lanes at the border crossing: Each Passport strategy is a different verification lane at the same border. Lane 1 checks JWTs (the 'jwt' strategy). A future 'local' lane checks username and password. OAuth lanes delegate to Google or GitHub. The border agent (guard) picks the lane based on the traveller’s situation. A passport token doesn’t get you through the API key lane — different lane, different check.

From Meteor? Meteor’s DDP session token was validated server-side by the Meteor framework invisibly. You never wrote a validate() method. In NestJS, JwtStrategy.validate() is explicit — you see every check: signature verified, user loaded from DB, status checked. You own the auth logic entirely.

Memory hook: JwtStrategy = border crossing lane named 'jwt'. validate() returns req.user. Re-query the DB on every request to catch banned accounts.

The validate flow:

  1. Request arrives with Authorization: Bearer eyJ...
  2. Passport extracts the JWT from the header
  3. Passport verifies the signature using JWT_PUBLIC_KEY and RS256 algorithm
  4. If valid, calls validate(payload) with the decoded payload
  5. validate() looks up the user from the DB, checks they are still active
  6. Returns { user: UserEntity } — this becomes req.user
  7. @CurrentUser() extracts req.user in the resolver

Why re-query the database in validate()?

The JWT payload contains the user ID but the payload is cached in the token (immutable until expiry). If you ban a user, their token is still valid until expiry. By querying the database on every request, you check the current user status and can immediately block suspended accounts.


7. AuthJwtGuard

// apps/api/src/modules/auth/guards/auth-jwt.guard.ts
import { AuthGuard } from '@nestjs/passport';

export class AuthJwtGuard extends AuthGuard('jwt') {
  // Inherits all logic from Passport's 'jwt' strategy
  // Override handleRequest() here if you need custom error handling
}

The gate officer: A guard is the gate officer at the hospital entrance. Before you reach any ward, the officer checks: Do you have a valid JWT? Is your pass for the correct zone? Is your pass still current? The officer doesn’t negotiate — it’s pass or block. And you don’t have one gate officer who does everything: AuthJwtGuard handles “is this a valid JWT?”, RolesGuard handles “does this role allow this action?” Each gate officer has one job.

From Meteor? Meteor’s .allow() and .deny() rules ran at the collection layer — after your method had already executed. AuthJwtGuard runs before the resolver method even starts. A rejected request never reaches the handler.

Memory hook: AuthJwtGuard = gate officer. One job: valid JWT or 401. Apply it to every mutation and sensitive query.

Usage in a resolver:

@UseGuards(AuthJwtGuard)  // ← everything below this runs ONLY if JWT is valid
@Mutation(() => TodoDto)
async createTodo(
  @CurrentUser() currentUser: AccessTokenUser,
  @Args('input') input: CreateTodoInput,
) {
  // JWT is verified before we reach here
  // currentUser.user is a UserEntity — fully typed
}

8. @CurrentUser() Decorator

// apps/api/src/modules/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AccessTokenUser } from '../auth.interface';

export const CurrentUser = createParamDecorator(
  (_data: unknown, context: ExecutionContext): AccessTokenUser => {
    // For GraphQL, we extract from the GraphQL execution context
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

Why a custom decorator? NestJS’s default @Request() decorator gives you the raw Express request object. @CurrentUser() gives you the typed AccessTokenUser from req.user — already verified by the guard, fully typed, with the UserEntity attached.

The sticky label: @CurrentUser() is a custom sticky label that NestJS reads at the parameter level. It tells the framework: “inject the authenticated user here, not the raw request object.” The guard placed the user on req.user; the decorator retrieves it with the correct TypeScript type. Without the guard having run first, there is nothing to retrieve.

From Meteor? this.userId inside a Meteor method gave you the current user’s ID — implicitly, from the DDP session. @CurrentUser() is the explicit equivalent: the user object is injected into the resolver parameter, fully typed, and only available because AuthJwtGuard verified the JWT first. No session magic.

Memory hook: @CurrentUser() = typed extraction of req.user. Only works after AuthJwtGuard has run. Returns AccessTokenUser, not the raw request.


9. Auth Resolver

// apps/api/src/modules/auth/auth.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';

import { AuthService } from './auth.service';
import { AuthTokensDto } from './dto/auth.dto';
import { RegisterInput, RefreshTokenInput, SignInInput } from './dto/auth.input';
import { AuthJwtGuard } from './guards/auth-jwt.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { AccessTokenUser } from './auth.interface';
import { UserDto } from '../user/dto/user.dto';

@Resolver()
export class AuthResolver {
  constructor(private readonly authService: AuthService) {}

  // Public — no guard
  @Mutation(() => AuthTokensDto)
  async register(@Args('input') input: RegisterInput): Promise<AuthTokensDto> {
    return this.authService.register(input);
  }

  // Public — no guard
  @Mutation(() => AuthTokensDto)
  async signIn(@Args('input') input: SignInInput): Promise<AuthTokensDto> {
    return this.authService.signIn(input);
  }

  // Public — refresh token is the credential
  @Mutation(() => AuthTokensDto)
  async refreshToken(@Args('input') input: RefreshTokenInput): Promise<AuthTokensDto> {
    return this.authService.refreshToken(input.refreshToken);
  }

  // Protected — requires valid access token
  @UseGuards(AuthJwtGuard)
  @Query(() => UserDto)
  async me(@CurrentUser() currentUser: AccessTokenUser): Promise<UserDto> {
    return currentUser.user as UserDto;
  }
}

10. Auth Module

// apps/api/src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';

import { UserEntity } from '../user/user.entity';
import { AuthResolver } from './auth.resolver';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([UserEntity]),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({}),  // configured per-call in service (privateKey varies)
  ],
  providers: [
    AuthResolver,
    AuthService,
    JwtStrategy,    // registers the Passport strategy with NestJS DI
  ],
  exports: [JwtStrategy, PassportModule],  // export so other modules can use the guard
})
export class AuthModule {}

Register in AppModule:

import { AuthModule } from './modules/auth/auth.module';
import { UserEntity } from './modules/user/user.entity';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      useFactory: () => ({
        entities: [UserEntity, /* ... */],
      }),
    }),
    AuthModule,
  ],
})
export class AppModule {}

11. ValidationPipe — The Global Guard

ValidationPipe runs class-validator decorators on every input automatically. It is registered in main.ts:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,              // strip unknown fields
    forbidNonWhitelisted: true,   // reject requests with unknown fields
    transform: true,              // convert JSON to typed DTO instances
  }),
);

The customs hall: The ValidationPipe is the customs hall at the hospital entrance. Every incoming intake form passes through inspection before reaching the specialist. Undeclared items (whitelist: true) are confiscated before entry. Contraband fields (forbidNonWhitelisted: true) turn the traveller away entirely — the request never reaches your resolver. Only cleared, certified intake forms reach the handler.

From Meteor? check(input, String) was the Meteor equivalent — optional, per-method, and it only validated type, not shape. It never stripped unknown fields. ValidationPipe with whitelist: true + forbidNonWhitelisted: true is global, automatic, and actively hostile to unknown fields.

Memory hook: ValidationPipe = customs hall. whitelist strips unknown fields. forbidNonWhitelisted rejects the entire request. Both must be on to close the attack surface.

Why forbidNonWhitelisted: true?

Without it:

POST /graphql
{ "mutation": "createUser(input: { email: 'x', password: 'y', isAdmin: true })" }

whitelist: true alone would strip isAdmin silently — the request passes. If a developer forgot to add isAdmin validation but the field was mapped by TypeORM, it could be set.

With forbidNonWhitelisted: true:

{ "statusCode": 400, "message": ["property isAdmin should not exist"] }

The request is rejected. The attack surface is explicit.


12. @IsUndefined() vs @IsOptional() — The Partial Update Problem

For update input DTOs, you want fields to be optional — but with a subtle difference.

The problem with @IsOptional()

@InputType()
export class UpdateTodoInput {
  @Field({ nullable: true })
  @IsOptional()  // ← WRONG for required fields
  @IsString()
  text?: string;
}

@IsOptional() skips all validation if the value is null or undefined. This means:

But todo.text is required (NOT NULL in the DB). Setting it to null causes a database error (or silently corrupts data if the column is nullable).

The solution: @IsUndefined()

@InputType()
export class UpdateTodoInput {
  @Field({ nullable: true })
  @IsUndefined({ each: false })  // ← validates: if present, must not be null
  @IsString()
  text?: string;
}

@IsUndefined() means: if the field is present in the request, it must be undefined (i.e., the field was omitted). If the client explicitly sends null, it is NOT undefined, so the validation fails with 400.

In practice:

From Meteor? MongoDB’s flexible schema means a document field could silently be null when you expected a string — there was no equivalent of @IsUndefined(). The NestJS pattern of using @IsUndefined() on partial update inputs is a deliberate contract: omitted = “don’t change this”, explicit null = “rejected”.

Memory hook: @IsUndefined() vs @IsOptional() — omitted is safe, null is rejected. Use @IsUndefined() on required fields in update DTOs.


13. Dual Auth: User vs Admin Portal

The codebase maintains two separate auth stacks:

User AuthAdmin Portal Auth
JWT key pairJWT_PRIVATE_KEY / JWT_PUBLIC_KEYADMIN_JWT_PRIVATE_KEY / ADMIN_JWT_PUBLIC_KEY
Passport strategyJwtStrategy ('jwt')PortalJwtStrategy ('portal-jwt')
GuardAuthJwtGuardPortalAuthJwtGuard
AudienceEnd users (web/mobile app)Admin operators (admin portal)

The admin portal has its own portal-auth module, its own resolver, its own strategy. The two stacks are completely independent.

Why this matters: A stolen user JWT cannot be replayed against admin endpoints. The PortalAuthJwtGuard uses PortalJwtStrategy which verifies against ADMIN_JWT_PUBLIC_KEY. The user JWT was signed with JWT_PRIVATE_KEY — the signature verification against ADMIN_JWT_PUBLIC_KEY will fail.

From Meteor? Meteor had a single auth layer — Meteor.userId() was the same whether the caller was a customer or an internal admin tool. NestJS dual-auth uses completely separate RSA key pairs, separate Passport strategies, and separate guards. A customer JWT literally cannot pass the admin guard — the cryptographic key pair is different.

Memory hook: Dual-auth = two separate key rings. User JWT signed with JWT_PRIVATE_KEY; admin JWT signed with ADMIN_JWT_PRIVATE_KEY. Stolen user token cannot forge admin access.

The admin strategy also checks roles:

// portal-jwt.strategy.ts
async validate(payload: JwtPayload): Promise<PortalTokenUser> {
  const portalUser = await this.portalUserRepo.findOne({
    where: { id: payload.sub },
    relations: ['roles'],
  });
  if (!portalUser) throw new UnauthorizedException();
  if (!portalUser.isActive) throw new UnauthorizedException('Account disabled');
  return { portalUser };
}

14. Complete Authentication Flow

REGISTRATION:
Client → POST /graphql { mutation: register(input: {...}) }
  → ValidationPipe validates RegisterInput
  → AuthResolver.register() called
  → AuthService.register():
      1. Check username/email uniqueness
      2. bcrypt.hash(password, 12)
      3. repo.save(user)
      4. generateTokens(user) → RS256 signed accessToken + refreshToken
  → Return { accessToken, refreshToken }
  → Client stores accessToken in memory, refreshToken in httpOnly cookie or localStorage

SUBSEQUENT REQUESTS:
Client → POST /graphql { Authorization: Bearer <accessToken> }
  → AuthJwtGuard triggers JwtStrategy
  → JwtStrategy.validate():
      1. Verify RS256 signature against JWT_PUBLIC_KEY
      2. Decode payload: { sub: 1, username: "alice" }
      3. DB query: userRepo.findOne({ where: { id: 1 } })
      4. Check user.status === ACTIVE
      5. Return { user: UserEntity } → attached to req.user
  → Resolver executes with @CurrentUser() giving full UserEntity

TOKEN REFRESH:
Client → POST /graphql { mutation: refreshToken(input: { refreshToken: "..." }) }
  → AuthService.refreshToken():
      1. Verify refresh token against JWT_REFRESH_PUBLIC_KEY
      2. Decode payload, look up user
      3. generateTokens(user) → new accessToken + refreshToken
  → Client replaces stored tokens

15. Security Checklist

Run through this for every new module:

[ ] All mutations have @UseGuards(AuthJwtGuard) — or are explicitly public
[ ] Sensitive queries have @UseGuards(AuthJwtGuard)
[ ] userId is NOT a @Field() on any input DTO (set server-side from JWT)
[ ] Update/delete operations filter by userId: { eq: currentUser.user.id }
[ ] Partial update inputs use @IsUndefined() not @IsOptional() for required fields
[ ] Passwords are hashed with bcrypt (never stored in plain text or encrypted)
[ ] No secrets in code — all from ConfigService / env vars
[ ] New entities soft-delete sensitive data (never hard-delete permissions, audit records)
[ ] New public endpoints are rate-limited with @nestjs/throttler

16. Testing Auth in the GraphQL Playground

With the AuthModule registered, test the full auth flow:

Step 1: Register

mutation {
  register(input: {
    fullname: "Alice Developer"
    username: "alice"
    email: "alice@example.com"
    password: "Secret123!"
  }) {
    accessToken
    refreshToken
  }
}

Step 2: Copy the accessToken, paste into HTTP Headers:

{ "Authorization": "Bearer eyJhbGci..." }

Step 3: Query the authenticated user:

query {
  me {
    id
    fullname
    email
    status
  }
}

Step 4: Try an authenticated mutation:

mutation {
  createTodo(input: { text: "Buy groceries" }) {
    id
    text
    isChecked
  }
}

Step 5: Try without the Authorization header:

mutation {
  createTodo(input: { text: "This should fail" }) { id }
}

Expected response: { "errors": [{ "message": "Unauthorized" }] }


17. Migrating Part 06 Code to Auth

Part 06 built a working but unauthenticated resolver with userId as an explicit @Field(). Now that auth is in place, make two changes to the todo module:

Step 1 — Remove userId from CreateTodoInput:

// apps/api/src/modules/todo/dto/todo.input.ts
@InputType()
export class CreateTodoInput {
  @Field()
  @IsString()
  @IsNotEmpty({ message: 'Todo text cannot be empty' })
  @MaxLength(500, { message: 'Todo text cannot exceed 500 characters' })
  text: string;

  // userId removed from @Field() — injected from JWT in the resolver
  userId?: number;
}

Step 2 — Update todo.resolver.ts with guards and ownership filters:

// apps/api/src/modules/todo/todo.resolver.ts
import { UseGuards } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { AuthJwtGuard } from '../auth/guards/auth-jwt.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AccessTokenUser } from '../auth/auth.interface';
import { TodoDto } from './dto/todo.dto';
import { CreateTodoInput, UpdateTodoInput } from './dto/todo.input';
import { TodosQuery, TodoQueryConnection } from './dto/todo.query';
import {
  CountTodoQuery, CreateOneTodoCommand, DeleteOneTodoCommand,
  FindManyTodoQuery, FindOneTodoQuery, UpdateOneTodoCommand,
} from './cqrs';

@Resolver(() => TodoDto)
export class TodoResolver {
  constructor(
    private readonly queryBus: QueryBus,
    private readonly commandBus: CommandBus,
  ) {}

  @Query(() => TodoDto, { nullable: true })
  async todo(@Args('id', { type: () => Int }) id: number): Promise<TodoDto | null> {
    const { data } = await this.queryBus.execute(
      new FindOneTodoQuery({ query: { filter: { id: { eq: id } } } }),
    );
    return data as TodoDto;
  }

  @UseGuards(AuthJwtGuard)
  @Query(() => TodoQueryConnection)
  async getTodos(
    @CurrentUser() currentUser: AccessTokenUser,
    @Args() query: TodosQuery,
  ) {
    const userFilter = { userId: { eq: currentUser.user.id } };

    return TodoQueryConnection.createFromPromise(
      async (q) => {
        const { data } = await this.queryBus.execute(new FindManyTodoQuery({ query: q }));
        return data as TodoDto[];
      },
      {
        ...query,
        filter: query.filter ? { and: [query.filter, userFilter] } : userFilter,
      },
      async (filter) => {
        const { data: count } = await this.queryBus.execute(new CountTodoQuery({ query: filter }));
        return count as number;
      },
    );
  }

  @UseGuards(AuthJwtGuard)
  @Mutation(() => TodoDto)
  async createTodo(
    @CurrentUser() currentUser: AccessTokenUser,
    @Args('input') input: CreateTodoInput,
  ): Promise<TodoDto> {
    const { data } = await this.commandBus.execute(
      new CreateOneTodoCommand({
        input: { ...input, userId: currentUser.user.id },  // ← inject from JWT
      }),
    );
    return data as TodoDto;
  }

  @UseGuards(AuthJwtGuard)
  @Mutation(() => TodoDto)
  async updateTodo(
    @CurrentUser() currentUser: AccessTokenUser,
    @Args('id', { type: () => Int }) id: number,
    @Args('input') input: UpdateTodoInput,
  ): Promise<TodoDto> {
    const { data } = await this.commandBus.execute(
      new UpdateOneTodoCommand({
        query: { filter: { id: { eq: id }, userId: { eq: currentUser.user.id } } },
        input,
      }),
    );
    return data.updated as TodoDto;
  }

  @UseGuards(AuthJwtGuard)
  @Mutation(() => Boolean)
  async deleteTodo(
    @CurrentUser() currentUser: AccessTokenUser,
    @Args('id', { type: () => Int }) id: number,
  ): Promise<boolean> {
    await this.commandBus.execute(
      new DeleteOneTodoCommand({ input: { id, userId: currentUser.user.id } }),  // ← ownership filter
    );
    return true;
  }
}

Step 3 — Update DeleteOneTodoCommand and deleteOneTodo service for ownership:

The delete command was originally defined with a plain number input (Part 06). Now that auth is in place, it must carry userId so the service can verify ownership before deleting:

// apps/api/src/modules/todo/cqrs/todo.cqrs.input.ts  (Part 08 update)
export class DeleteOneTodoCommand extends AbstractCqrsCommandInput<
  TodoEntity,
  { id: number; userId: number }  // ← was: number
> {}
// apps/api/src/modules/todo/todo.service.ts  (Part 08 update)
deleteOneTodo: CqrsCommandFunc<
  DeleteOneTodoCommand,
  DeleteOneTodoCommand['args']
> = async ({ input: { id, userId } }) => {  // ← destructure both
  try {
    const todo = await this.repo.findOne({ where: { id, userId } });
    if (!todo) throw new NotFoundException('Todo not found');
    await this.repo.remove(todo);
    return { success: true, data: todo };
  } catch (e) {
    throw new BadRequestException(e.message);
  }
};

Security note: Without the userId filter, any authenticated user could delete any todo by id. Always filter mutations by both the record id and the JWT-extracted userId.

The create and update CQRS inputs and their service methods are otherwise unchanged.


18. Frontend: Apollo Client with Auth

With the backend issuing JWTs, update the Next.js frontend to send the token on every request.

18.1 Update Apollo Client

// apps/web/src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';

const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3333/graphql',
});

const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
  return {
    headers: {
      ...headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
  };
});

const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (process.env.NODE_ENV === 'development') {
    graphQLErrors?.forEach(({ message, locations, path }) =>
      console.error(`GraphQL error: ${message}`, { locations, path }),
    );
    if (networkError) console.error('Network error:', networkError);
  }
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, authLink, httpLink]),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          getTodos: {
            keyArgs: ['filter', 'sorting'],
            merge(existing, incoming) {
              return {
                ...incoming,
                edges: [...(existing?.edges ?? []), ...(incoming?.edges ?? [])],
              };
            },
          },
        },
      },
    },
  }),
});

18.2 Auth GraphQL Operations

// apps/web/src/graphql/auth.operations.ts
import { gql } from '@apollo/client';

export const REGISTER = gql`
  mutation Register($input: RegisterInput!) {
    register(input: $input) {
      accessToken
      refreshToken
    }
  }
`;

export const LOGIN = gql`
  mutation Login($input: LoginInput!) {
    login(input: $input) {
      accessToken
      refreshToken
    }
  }
`;

export const ME = gql`
  query Me {
    me {
      id
      fullname
      email
    }
  }
`;

18.3 Auth Hook

// apps/web/src/hooks/use-auth.ts
'use client';

import { useMutation } from '@apollo/client/react';  // v4: React APIs moved to /react
import { useRouter } from 'next/navigation';
import { apolloClient } from '../lib/apollo-client';
import { LOGIN } from '../graphql/auth.operations';

export function useAuth() {
  const router = useRouter();
  const [loginMutation, { loading }] = useMutation(LOGIN);

  const login = async (email: string, password: string) => {
    const { data } = await loginMutation({
      variables: { input: { email, password } },
    });
    if (data?.login?.accessToken) {
      localStorage.setItem('accessToken', data.login.accessToken);
      router.push('/');
    }
  };

  const logout = () => {
    localStorage.removeItem('accessToken');
    apolloClient.clearStore();
    router.push('/login');
  };

  return { login, logout, loading };
}

Token storage: localStorage is fine for development. For production, store the accessToken in memory (React ref or Zustand) and the refreshToken in an httpOnly cookie — this prevents XSS from stealing long-lived tokens.


Quick Reference

ConceptAnalogyMeteor equivalentThe one rule
RS256 JWTKing’s wax seal — private key signs, public key verifiesDDP session token (shared secret)Use RS256, not HS256. Downstream breach cannot forge tokens.
HS256 JWTMaster key — whoever has it can lock and unlockNever use HS256 in multi-service arch
Passport StrategyID verification lane at a border crossingNo equivalent — Meteor auth is a black boxEach strategy is one named lane. validate() returns req.user.
AuthJwtGuardGate officer.allow() / .deny() — runs at DB layerReturns true or throws. Runs before the resolver.
AuthServiceSpecialist doctorAccounts.createUser() / Accounts.loginWithPassword()All credential logic here. Never in resolver.
@CurrentUser()Sticky label that retrieves typed user from req.userthis.userId inside a Meteor methodOnly works after AuthJwtGuard has run. Returns AccessTokenUser.
ValidationPipeCustoms hallcheck(input, String) — optional, per-methodwhitelist: true strips; forbidNonWhitelisted: true rejects. Both required.
@IsUndefined()Partial update contractNo equivalentOmitted = skip field. Explicit null = 400. Use on required fields in update DTOs.
Dual-authTwo separate key ringsSingle Meteor.userId() for all callersUser JWT and admin JWT use different RSA key pairs. Cannot cross lanes.

Summary

MeteorEnterprise NestJS
accounts-base (implicit)Passport JWT strategy (explicit, auditable)
Meteor.userId()@CurrentUser() user: AccessTokenUser (from verified JWT)
.allow() / .deny()@UseGuards(AuthJwtGuard) on every mutation/query
Single auth layerDual-auth: user JWT + admin portal JWT (separate key pairs)
No password policy@MinLength(8) + @Matches(/(?=.*[A-Z])/) + bcrypt(12)
DDP session tokenRS256 JWT (access + refresh)
check(text, String)class-validator + ValidationPipe (globally enforced)
No concept@IsUndefined() for safe partial updates
No conceptuserId never a @Field() — injected from JWT server-side

What You Have Now

After Parts 01-08, you have:

In Part 09, we extend the auth system with email verification, password reset via single-use tokens, and TOTP 2FA — before moving to the case studies in Parts 10 and 11.


Edit page
Share this post:

Next Post
Extended Auth — Email Service, Secured Tokens & Two-Factor Authentication
Previous Post
GraphQL API + Next.js Frontend