What This Part Covers
- Why RS256 (RSA asymmetric JWT) instead of HS256 (HMAC)
- Generating and managing RSA key pairs
- Implementing the
AuthModule: register, sign-in, refresh token PassportJWT strategy — how it validates incoming requestsAuthJwtGuard— the@UseGuardsthat protects mutations and queries@CurrentUser()— the custom decorator that injects the authenticated userValidationPipewithforbidNonWhitelisted— the global protection layer- The ownership scoping pattern: why
userIdis never a@Field() @IsUndefined()vs@IsOptional()for partial updates- Dual-auth architecture: user auth vs admin portal auth
Meteor Equivalents
| Meteor | NestJS | Difference |
|---|---|---|
accounts-base + accounts-password | PassportModule + JwtModule + bcrypt | Explicit implementation, auditable |
Meteor.userId() | @CurrentUser() user: AccessTokenUser | Injected 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 login | POST /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:
- Compartmentalisation. A compromised downstream service cannot forge JWTs — it only has the public key.
- Independent rotation. Rotate the user access key without affecting the admin portal key.
- 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
.envfiles 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
AuthServiceis 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 theAuthResolver’s job. All security decisions live here, not in the resolver.
From Meteor? In Meteor,
Accounts.createUser()andAccounts.loginWithPassword()were black-box framework calls — you got auth “for free” but had no visibility into what they did. NestJSAuthServiceis explicit: you see every step, every hashing round, every token generation. When a security audit asks “how do you hash passwords?”, you openauth.service.tsand show them.
Memory hook: AuthService = specialist doctor. Hashes, verifies, generates tokens. All credential logic lives here, never in the resolver.
Key security decisions:
bcrypt.hash(password, 12)— bcrypt with 12 rounds. Higher rounds = harder to brute-force. 12 is the production standard.UnauthorizedException('Invalid credentials')— the same error message for both “user not found” and “wrong password”. Never reveal which one failed (timing attack mitigation).- Refresh token uses a different private key than the access token. A stolen access token cannot generate new access tokens.
replace(/\\n/g, '\n')— PEM keys in.envhave\nas 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:
- Request arrives with
Authorization: Bearer eyJ... - Passport extracts the JWT from the header
- Passport verifies the signature using
JWT_PUBLIC_KEYand RS256 algorithm - If valid, calls
validate(payload)with the decoded payload validate()looks up the user from the DB, checks they are still active- Returns
{ user: UserEntity }— this becomesreq.user @CurrentUser()extractsreq.userin 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:
AuthJwtGuardhandles “is this a valid JWT?”,RolesGuardhandles “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.AuthJwtGuardruns 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 onreq.user; the decorator retrieves it with the correct TypeScript type. Without the guard having run first, there is nothing to retrieve.
From Meteor?
this.userIdinside 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 becauseAuthJwtGuardverified 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
ValidationPipeis 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.ValidationPipewithwhitelist: true+forbidNonWhitelisted: trueis 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:
- Client sends
{ "text": null }→@IsOptional()skips@IsString()→nullpasses →textis set tonullin DB
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:
- Field omitted →
undefined→ passes validation → field is not updated - Field set to
null→ notundefined→ 400 Bad Request - Field set to a valid string →
@IsString()validates normally → field is updated
From Meteor? MongoDB’s flexible schema means a document field could silently be
nullwhen 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 Auth | Admin Portal Auth | |
|---|---|---|
| JWT key pair | JWT_PRIVATE_KEY / JWT_PUBLIC_KEY | ADMIN_JWT_PRIVATE_KEY / ADMIN_JWT_PUBLIC_KEY |
| Passport strategy | JwtStrategy ('jwt') | PortalJwtStrategy ('portal-jwt') |
| Guard | AuthJwtGuard | PortalAuthJwtGuard |
| Audience | End 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
userIdfilter, any authenticated user could delete any todo by id. Always filter mutations by both the record id and the JWT-extracteduserId.
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:
localStorageis fine for development. For production, store theaccessTokenin memory (React ref or Zustand) and therefreshTokenin anhttpOnlycookie — this prevents XSS from stealing long-lived tokens.
Quick Reference
| Concept | Analogy | Meteor equivalent | The one rule |
|---|---|---|---|
| RS256 JWT | King’s wax seal — private key signs, public key verifies | DDP session token (shared secret) | Use RS256, not HS256. Downstream breach cannot forge tokens. |
| HS256 JWT | Master key — whoever has it can lock and unlock | — | Never use HS256 in multi-service arch |
| Passport Strategy | ID verification lane at a border crossing | No equivalent — Meteor auth is a black box | Each strategy is one named lane. validate() returns req.user. |
| AuthJwtGuard | Gate officer | .allow() / .deny() — runs at DB layer | Returns true or throws. Runs before the resolver. |
| AuthService | Specialist doctor | Accounts.createUser() / Accounts.loginWithPassword() | All credential logic here. Never in resolver. |
@CurrentUser() | Sticky label that retrieves typed user from req.user | this.userId inside a Meteor method | Only works after AuthJwtGuard has run. Returns AccessTokenUser. |
| ValidationPipe | Customs hall | check(input, String) — optional, per-method | whitelist: true strips; forbidNonWhitelisted: true rejects. Both required. |
@IsUndefined() | Partial update contract | No equivalent | Omitted = skip field. Explicit null = 400. Use on required fields in update DTOs. |
| Dual-auth | Two separate key rings | Single Meteor.userId() for all callers | User JWT and admin JWT use different RSA key pairs. Cannot cross lanes. |
Summary
| Meteor | Enterprise 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 layer | Dual-auth: user JWT + admin portal JWT (separate key pairs) |
| No password policy | @MinLength(8) + @Matches(/(?=.*[A-Z])/) + bcrypt(12) |
| DDP session token | RS256 JWT (access + refresh) |
check(text, String) | class-validator + ValidationPipe (globally enforced) |
| No concept | @IsUndefined() for safe partial updates |
| No concept | userId never a @Field() — injected from JWT server-side |
What You Have Now
After Parts 01-08, you have:
- ✅ Full environment (Node, Yarn, Docker, VS Code, Nx workspace)
- ✅ NestJS app with GraphQL, TypeORM, CQRS
- ✅ PostgreSQL + Redis in Docker
- ✅ Entity + migration pattern
- ✅ Full CQRS pipeline: Command → Handler → Service → Repository
- ✅ GraphQL DTOs: @ObjectType, @InputType, cursor pagination
- ✅ Next.js frontend with Apollo Client + auth token injection
- ✅ RS256 JWT authentication, Passport strategy, guards, decorators
- ✅ Global ValidationPipe with security hardening
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.