Skip to content

Auth & Security

Authentication verifies identity; authorization enforces permissions. Warmwind uses JWT with Passport.js, refresh token rotation, OAuth2 PKCE for SSO, RBAC via CASL, and defense-in-depth layers (rate limiting, Helmet, CORS, CSP, Argon2id, class-validator, API key management). Every pattern is shown with production-grade NestJS code and the rationale behind each decision.


1. JWT Authentication with Passport.js

1.1 Full Auth Flow

sequenceDiagram
    participant C as Client (SPA)
    participant A as NestJS API
    participant DB as PostgreSQL
    participant R as Redis (token store)

    C->>A: POST /auth/login { email, password }
    A->>DB: SELECT password_hash FROM users WHERE email = $1
    A->>A: argon2.verify(hash, password)
    A->>R: Store refresh token (family, userId, jti)
    A-->>C: 200 { accessToken (15m), refreshToken (7d) }

    Note over C: Stores accessToken in memory,<br/>refreshToken in httpOnly cookie

    C->>A: GET /sessions (Authorization: Bearer <accessToken>)
    A->>A: JwtStrategy.validate() extracts payload
    A->>A: RolesGuard checks permissions
    A-->>C: 200 [sessions...]

    C->>A: POST /auth/refresh (cookie: refreshToken)
    A->>R: Validate token, check revoked flag
    A->>R: Revoke old token, issue new pair
    A-->>C: 200 { accessToken, refreshToken }

1.2 JWT Strategy Implementation

// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

export interface JwtPayload {
  sub: string;       // user ID
  email: string;
  tenantId: string;
  roles: string[];
  iat: number;
  exp: number;
  jti: string;       // unique token ID for revocation
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
      algorithms: ['HS256'],
      ignoreExpiration: false,
    });
  }

  async validate(payload: JwtPayload) {
    // Returned object is attached to req.user
    return {
      id: payload.sub,
      email: payload.email,
      tenantId: payload.tenantId,
      roles: payload.roles,
    };
  }
}

Coming from Spring Security

In Spring, JwtDecoder is configured via SecurityFilterChain with .oauth2ResourceServer(oauth2 -> oauth2.jwt()). Passport.js strategies serve the same role but are explicit callback-based rather than declarative. The key mapping: Spring's JwtAuthenticationConverter is TypeORM's validate() method -- it transforms the raw JWT payload into your application's user representation.

1.3 Auth Service -- Token Issuance

// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import { UserRepository } from '../users/user.repository';
import { RefreshTokenRepository } from './refresh-token.repository';

@Injectable()
export class AuthService {
  constructor(
    private jwt: JwtService,
    private config: ConfigService,
    private users: UserRepository,
    private refreshTokens: RefreshTokenRepository,
  ) {}

  async login(email: string, password: string) {
    const user = await this.users.findByEmailWithPassword(email);
    if (!user) throw new UnauthorizedException('Invalid credentials');

    const valid = await argon2.verify(user.passwordHash, password);
    if (!valid) throw new UnauthorizedException('Invalid credentials');

    return this.issueTokenPair(user.id, user.email, user.tenantId, user.roles);
  }

  private async issueTokenPair(
    userId: string,
    email: string,
    tenantId: string,
    roles: string[],
    family?: string,
  ) {
    const tokenFamily = family ?? uuidv4();
    const jti = uuidv4();

    const accessToken = this.jwt.sign(
      { sub: userId, email, tenantId, roles, jti },
      {
        secret: this.config.getOrThrow('JWT_ACCESS_SECRET'),
        expiresIn: '15m',
      },
    );

    const refreshToken = this.jwt.sign(
      { sub: userId, family: tokenFamily, jti: uuidv4() },
      {
        secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
        expiresIn: '7d',
      },
    );

    // Persist refresh token for rotation tracking
    await this.refreshTokens.create({
      token: refreshToken,
      userId,
      family: tokenFamily,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    });

    return { accessToken, refreshToken };
  }
}

2. Refresh Token Rotation

2.1 Why Rotation?

A stolen refresh token is dangerous -- it grants long-lived access. Rotation ensures each refresh token is single-use. If an attacker replays a previously used token, the entire token family is revoked, forcing re-login.

2.2 Implementation

// auth/auth.service.ts (continued)
async refreshTokens(oldRefreshToken: string) {
  let decoded: { sub: string; family: string; jti: string };
  try {
    decoded = this.jwt.verify(oldRefreshToken, {
      secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
    });
  } catch {
    throw new UnauthorizedException('Invalid refresh token');
  }

  const stored = await this.refreshTokens.findByToken(oldRefreshToken);

  // Case 1: token not found or already revoked -> token reuse attack
  if (!stored || stored.revoked) {
    // Revoke the ENTIRE family -- attacker and legitimate user both lose access
    await this.refreshTokens.revokeFamily(decoded.family);
    throw new UnauthorizedException('Token reuse detected -- family revoked');
  }

  // Case 2: valid token -- rotate
  await this.refreshTokens.revoke(stored.id);

  const user = await this.users.findByIdOrFail(decoded.sub);
  return this.issueTokenPair(
    user.id, user.email, user.tenantId, user.roles,
    decoded.family,  // preserve the family for tracking
  );
}

2.3 Refresh Token Entity

// entities/refresh-token.entity.ts
@Entity('refresh_tokens')
@Index(['family'])
@Index(['userId'])
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'text' })
  token: string;

  @Column({ type: 'uuid', name: 'user_id' })
  userId: string;

  @Column({ type: 'uuid' })
  family: string;

  @Column({ default: false })
  revoked: boolean;

  @Column({ type: 'timestamptz', name: 'expires_at' })
  expiresAt: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

2.4 Database Cleanup

-- Periodic job: delete expired and revoked tokens
DELETE FROM refresh_tokens
WHERE  expires_at < NOW() - INTERVAL '1 day'
   OR  revoked = true;

3. OAuth2 Authorization Code + PKCE

3.1 Why PKCE?

PKCE (RFC 7636) prevents authorization code interception attacks. Without it, a malicious app that intercepts the redirect URI can exchange the auth code. PKCE binds the code to a code_verifier that only the original client knows.

3.2 Sequence Diagram

sequenceDiagram
    participant B as Browser (SPA)
    participant A as NestJS API
    participant IdP as Identity Provider (Entra ID)

    Note over B: Generate code_verifier (43-128 chars)<br/>code_challenge = SHA256(verifier) base64url

    B->>A: GET /auth/oidc/login
    A-->>B: 302 → IdP /authorize?<br/>response_type=code&<br/>client_id=xxx&<br/>redirect_uri=.../callback&<br/>scope=openid profile email&<br/>code_challenge=abc123&<br/>code_challenge_method=S256&<br/>state=random-csrf-token

    B->>IdP: User authenticates (MFA, password, etc.)
    IdP-->>B: 302 → /auth/oidc/callback?code=AUTH_CODE&state=random-csrf-token

    B->>A: GET /auth/oidc/callback?code=AUTH_CODE&state=...

    A->>IdP: POST /token {<br/>  grant_type: authorization_code,<br/>  code: AUTH_CODE,<br/>  code_verifier: original_verifier,<br/>  client_id: xxx,<br/>  redirect_uri: .../callback<br/>}

    IdP->>IdP: Verify SHA256(code_verifier) == code_challenge
    IdP-->>A: { id_token, access_token }

    A->>A: Validate id_token, extract claims
    A->>A: Find-or-create local user
    A-->>B: 200 { accessToken (15m), refreshToken (7d) }

3.3 NestJS Implementation

// auth/strategies/oidc.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Client, Issuer, generators } from 'openid-client';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
  constructor(private config: ConfigService) {
    const issuer = new Issuer({
      issuer: config.getOrThrow('OIDC_ISSUER'),
      authorization_endpoint: config.getOrThrow('OIDC_AUTH_ENDPOINT'),
      token_endpoint: config.getOrThrow('OIDC_TOKEN_ENDPOINT'),
      jwks_uri: config.getOrThrow('OIDC_JWKS_URI'),
      userinfo_endpoint: config.getOrThrow('OIDC_USERINFO_ENDPOINT'),
    });

    const client = new issuer.Client({
      client_id: config.getOrThrow('OIDC_CLIENT_ID'),
      client_secret: config.get('OIDC_CLIENT_SECRET'), // optional for public clients
      redirect_uris: [config.getOrThrow('OIDC_REDIRECT_URI')],
      response_types: ['code'],
      token_endpoint_auth_method: 'none', // PKCE public client
    });

    super({
      client,
      params: {
        scope: 'openid profile email',
        code_challenge_method: 'S256',
      },
      usePKCE: true,
    });
  }

  async validate(tokenset: any) {
    const claims = tokenset.claims();
    return {
      oidcId: claims.sub,
      email: claims.email,
      name: claims.name,
      // Tenant mapping happens in AuthService.findOrCreateOidcUser()
    };
  }
}
// auth/auth.controller.ts (OIDC routes)
@Controller('auth/oidc')
export class OidcController {
  constructor(private authService: AuthService) {}

  @Get('login')
  @UseGuards(AuthGuard('oidc'))
  login() {
    // Passport redirects to IdP
  }

  @Get('callback')
  @UseGuards(AuthGuard('oidc'))
  async callback(@Req() req: Request) {
    const user = await this.authService.findOrCreateOidcUser(req.user);
    return this.authService.issueTokenPair(
      user.id, user.email, user.tenantId, user.roles,
    );
  }
}

Coming from Spring Security

Spring Boot's spring-security-oauth2-client auto-configures PKCE when client-authentication-method: none is set. In NestJS, openid-client + passport with usePKCE: true is the equivalent. The main difference: Spring auto-discovers the IdP via .well-known/openid-configuration whereas openid-client also supports discovery via Issuer.discover(url).


4. RBAC with CASL

4.1 Why CASL over Simple Role Guards?

NestJS RolesGuard checks "does user have role X?" -- binary yes/no. CASL adds attribute-based rules: "can this admin manage containers in their own tenant?" and "can this viewer read sessions they own?"

4.2 Ability Factory

// auth/casl/ability.factory.ts
import { AbilityBuilder, PureAbility, createMongoAbility } from '@casl/ability';
import { Injectable } from '@nestjs/common';

export enum Action {
  Manage = 'manage', // wildcard: all actions
  Create = 'create',
  Read   = 'read',
  Update = 'update',
  Delete = 'delete',
}

export type AppAbility = PureAbility<[Action, string]>;

@Injectable()
export class AbilityFactory {
  createForUser(user: { id: string; tenantId: string; roles: string[] }): AppAbility {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

    if (user.roles.includes('admin')) {
      // Admin can manage everything within their tenant
      can(Action.Manage, 'all');
      cannot(Action.Delete, 'Tenant'); // even admins cannot delete the tenant itself
    }

    if (user.roles.includes('operator')) {
      can(Action.Create, 'AgentSession');
      can(Action.Read, 'AgentSession', { tenantId: user.tenantId });
      can(Action.Update, 'AgentSession', { userId: user.id }); // only own sessions
      can(Action.Read, 'Container', { tenantId: user.tenantId });
      can(Action.Read, 'AgentAction');
    }

    if (user.roles.includes('viewer')) {
      can(Action.Read, 'AgentSession', { tenantId: user.tenantId });
      can(Action.Read, 'Container', { tenantId: user.tenantId });
      can(Action.Read, 'AgentAction');
    }

    return build();
  }
}

4.3 CASL Guard

// auth/casl/policies.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, Action, AppAbility } from './ability.factory';

export interface PolicyHandler {
  handle(ability: AppAbility): boolean;
}

export const CHECK_POLICIES_KEY = 'check_policies';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
  SetMetadata(CHECK_POLICIES_KEY, handlers);

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private abilityFactory: AbilityFactory,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const handlers = this.reflector.get<PolicyHandler[]>(
      CHECK_POLICIES_KEY,
      context.getHandler(),
    ) ?? [];

    if (handlers.length === 0) return true;

    const user = context.switchToHttp().getRequest().user;
    const ability = this.abilityFactory.createForUser(user);

    const allowed = handlers.every((handler) => handler.handle(ability));
    if (!allowed) throw new ForbiddenException('Insufficient permissions');
    return true;
  }
}

4.4 Usage on Controller

// Policy handler objects
export class ReadSessionPolicy implements PolicyHandler {
  handle(ability: AppAbility) {
    return ability.can(Action.Read, 'AgentSession');
  }
}
export class DeleteContainerPolicy implements PolicyHandler {
  handle(ability: AppAbility) {
    return ability.can(Action.Delete, 'Container');
  }
}

// Controller
@Controller('sessions')
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class SessionController {
  @Get()
  @CheckPolicies(new ReadSessionPolicy())
  findAll(@Req() req: Request) { /* ... */ }

  @Delete(':id')
  @CheckPolicies(new DeleteContainerPolicy())
  remove(@Param('id') id: string) { /* ... */ }
}

5. Rate Limiting Auth Endpoints

// main.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { name: 'short',  ttl: 60_000,  limit: 20 },   // 20 req/min global
      { name: 'medium', ttl: 600_000, limit: 100 },   // 100 req/10min global
    ]),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
// auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  @Post('login')
  @Throttle({ short: { limit: 5, ttl: 60_000 } })      // 5 attempts/min
  async login(@Body() dto: LoginDto) { /* ... */ }

  @Post('refresh')
  @Throttle({ short: { limit: 3, ttl: 60_000 } })      // 3 refreshes/min
  async refresh(@Body() dto: RefreshDto) { /* ... */ }

  @Post('register')
  @Throttle({ short: { limit: 2, ttl: 3600_000 } })    // 2 registrations/hour
  async register(@Body() dto: RegisterDto) { /* ... */ }

  @Post('forgot-password')
  @Throttle({ short: { limit: 3, ttl: 3600_000 } })    // 3 resets/hour
  async forgotPassword(@Body() dto: ForgotPasswordDto) { /* ... */ }
}

IP-based throttling is not enough

Attackers use distributed IPs. Combine IP-based throttling with account-based throttling (lock after N failures per email) and progressive delays (exponential backoff before each retry).


6. Input Validation with class-validator

6.1 DTO Patterns

// dto/create-session.dto.ts
import {
  IsUUID, IsString, IsOptional, ValidateNested, IsObject,
  IsEnum, MaxLength, IsNotEmpty,
} from 'class-validator';
import { Type } from 'class-transformer';

class SessionConfigDto {
  @IsString() @MaxLength(50)
  model: string;

  @IsOptional() @IsObject()
  parameters?: Record<string, unknown>;
}

export class CreateSessionDto {
  @IsUUID()
  containerId: string;

  @IsOptional() @IsString() @MaxLength(200)
  description?: string;

  @IsOptional()
  @ValidateNested()
  @Type(() => SessionConfigDto)
  config?: SessionConfigDto;
}

6.2 Global Validation Pipe

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,            // strip properties not in DTO
    forbidNonWhitelisted: true, // throw if unknown properties present
    transform: true,            // auto-transform payloads to DTO instances
    transformOptions: {
      enableImplicitConversion: false, // explicit @Type() required
    },
  }),
);

6.3 Custom Validators

// validators/is-safe-string.validator.ts
import { registerDecorator, ValidationOptions } from 'class-validator';

export function IsSafeString(options?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isSafeString',
      target: object.constructor,
      propertyName,
      options,
      validator: {
        validate(value: unknown) {
          if (typeof value !== 'string') return false;
          // Block null bytes, control chars, and common injection patterns
          return !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(value);
        },
        defaultMessage() {
          return '$property contains invalid characters';
        },
      },
    });
  };
}

// Usage:
export class CreateContainerDto {
  @IsString() @Length(3, 63) @Matches(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/)
  @IsSafeString()
  name: string;
}

Coming from Spring Security

Spring uses @Valid + Bean Validation (JSR 380) with @NotBlank, @Size, @Pattern. NestJS's class-validator decorators map almost 1:1: @IsNotEmpty() = @NotBlank, @Length(min, max) = @Size(min, max), @Matches(regex) = @Pattern(regexp).


7. Security Headers -- Helmet, CORS, CSP

7.1 Helmet Configuration

// main.ts
import helmet from 'helmet';

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc:  ["'self'"],                            // no inline scripts
        styleSrc:   ["'self'", "'unsafe-inline'"],         // MkDocs needs inline styles
        imgSrc:     ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'", 'wss://vnc.warmwind.ag'],  // WebSocket for VNC
        fontSrc:    ["'self'"],
        objectSrc:  ["'none'"],
        frameSrc:   ["'none'"],                            // no iframes
        baseUri:    ["'self'"],
        formAction: ["'self'"],
      },
    },
    crossOriginEmbedderPolicy: true,
    crossOriginOpenerPolicy: { policy: 'same-origin' },
    crossOriginResourcePolicy: { policy: 'same-origin' },
    dnsPrefetchControl: { allow: false },
    frameguard: { action: 'deny' },
    hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
    ieNoOpen: true,
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    xssFilter: true,
  }),
);

7.2 CORS Configuration

app.enableCors({
  origin: [
    'https://app.warmwind.ag',
    'https://admin.warmwind.ag',
    ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
  ],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
  credentials: true,     // allow cookies (refresh token)
  maxAge: 86400,         // preflight cache: 24h
});

7.3 Response Headers Summary

Header Value Purpose
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Force HTTPS for 1 year
Content-Security-Policy (see above) Prevent XSS, data injection
X-Content-Type-Options nosniff Prevent MIME-type sniffing
X-Frame-Options DENY Prevent clickjacking
Referrer-Policy strict-origin-when-cross-origin Limit referrer leakage
Cross-Origin-Opener-Policy same-origin Isolate browsing context

8. Password Hashing -- Argon2id

8.1 Why Argon2id?

Algorithm Memory-Hard GPU-Resistant Side-Channel Resistant Recommended
bcrypt Partial Partial No Legacy
scrypt Yes Yes No Acceptable
Argon2d Yes Yes No Not for passwords
Argon2i Yes Partial Yes Not ideal
Argon2id Yes Yes Yes OWASP default

Argon2id combines Argon2d (data-dependent memory access, GPU-resistant) with Argon2i (data-independent, side-channel resistant) -- hybrid protection.

8.2 Implementation

// auth/password.service.ts
import * as argon2 from 'argon2';

@Injectable()
export class PasswordService {
  // OWASP 2024 recommended minimum: 19 MiB memory, 2 iterations, 1 parallelism
  // We use slightly stronger settings
  private readonly hashOptions: argon2.Options = {
    type: argon2.argon2id,
    memoryCost: 65536,  // 64 MiB -- tune per server (AWS t3.medium: 64 MiB is safe)
    timeCost: 3,        // 3 iterations
    parallelism: 1,     // 1 thread (avoid contention under load)
    hashLength: 32,     // 256-bit output
  };

  async hash(password: string): Promise<string> {
    return argon2.hash(password, this.hashOptions);
  }

  async verify(storedHash: string, inputPassword: string): Promise<boolean> {
    try {
      return await argon2.verify(storedHash, inputPassword);
    } catch {
      return false; // malformed hash, treat as mismatch
    }
  }

  needsRehash(storedHash: string): boolean {
    return argon2.needsRehash(storedHash, this.hashOptions);
  }
}

8.3 Rehash on Login (Parameter Migration)

// auth/auth.service.ts (in login method)
const valid = await this.passwords.verify(user.passwordHash, password);
if (!valid) throw new UnauthorizedException('Invalid credentials');

// Transparently upgrade hash parameters
if (this.passwords.needsRehash(user.passwordHash)) {
  const newHash = await this.passwords.hash(password);
  await this.users.updatePasswordHash(user.id, newHash);
}

Coming from Spring Security

Spring Security's DelegatingPasswordEncoder handles multi-algorithm hashes via {argon2}, {bcrypt} prefixes and auto-upgrades on login. The needsRehash() + re-hash-on-login pattern above is the manual equivalent in NestJS.


9. API Key Management

9.1 When to Use API Keys vs JWT

Use Case Mechanism Lifetime
Human user in browser JWT (access + refresh) 15m / 7d
Server-to-server (CI/CD, webhooks) API key Long-lived, manually rotated
Service mesh internal mTLS or JWT with short expiry Auto-rotated

9.2 API Key Generation and Storage

// auth/api-key.service.ts
import { randomBytes, createHash } from 'crypto';

@Injectable()
export class ApiKeyService {
  constructor(
    @InjectRepository(ApiKey) private repo: Repository<ApiKey>,
  ) {}

  async generate(userId: string, name: string): Promise<{ key: string; prefix: string }> {
    // Generate a 32-byte random key, encode as base64url
    const rawKey = randomBytes(32).toString('base64url');
    const prefix = rawKey.slice(0, 8);  // first 8 chars for identification

    // Store only the SHA-256 hash -- never store the raw key
    const hash = createHash('sha256').update(rawKey).digest('hex');

    await this.repo.save({
      prefix,
      keyHash: hash,
      userId,
      name,
      lastUsedAt: null,
    });

    // Return raw key to user ONCE -- cannot be recovered
    return { key: `ww_${rawKey}`, prefix };
  }

  async validate(apiKey: string): Promise<ApiKey | null> {
    if (!apiKey.startsWith('ww_')) return null;

    const rawKey = apiKey.slice(3);
    const hash = createHash('sha256').update(rawKey).digest('hex');

    const stored = await this.repo.findOne({
      where: { keyHash: hash, revoked: false },
      relations: { user: true },
    });

    if (stored) {
      // Update last used timestamp (fire-and-forget)
      this.repo.update(stored.id, { lastUsedAt: new Date() }).catch(() => {});
    }

    return stored;
  }

  async revoke(id: string, userId: string): Promise<void> {
    await this.repo.update({ id, userId }, { revoked: true });
  }
}

9.3 API Key Guard

// auth/guards/api-key.guard.ts
@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private apiKeyService: ApiKeyService) {}

  async canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers['x-api-key'];

    if (!apiKey) return false;

    const stored = await this.apiKeyService.validate(apiKey);
    if (!stored) return false;

    // Attach user to request, same shape as JWT
    request.user = {
      id: stored.user.id,
      email: stored.user.email,
      tenantId: stored.user.tenantId,
      roles: stored.user.roles,
    };

    return true;
  }
}

// Usage: @UseGuards(JwtOrApiKeyGuard) -- tries JWT first, falls back to API key

10. Putting It All Together -- Middleware Stack

flowchart TD
    REQ["Incoming Request"] --> HELMET["Helmet (security headers)"]
    HELMET --> CORS["CORS check"]
    CORS --> RATE["Rate Limiter (ThrottlerGuard)"]
    RATE --> VALIDATE["ValidationPipe (body/params)"]
    VALIDATE --> AUTH{"Auth Guard"}
    AUTH -->|JWT| JWT_STRAT["JwtStrategy.validate()"]
    AUTH -->|API Key| KEY_STRAT["ApiKeyGuard.canActivate()"]
    AUTH -->|OIDC| OIDC_STRAT["OidcStrategy.validate()"]
    JWT_STRAT --> AUTHZ["PoliciesGuard (CASL)"]
    KEY_STRAT --> AUTHZ
    OIDC_STRAT --> AUTHZ
    AUTHZ --> TENANT["TenantMiddleware (SET app.current_tenant)"]
    TENANT --> HANDLER["Route Handler"]
    HANDLER --> RES["Response"]

Glossary

Glossary

JWT (JSON Web Token)
Compact, URL-safe token containing signed JSON claims. The access token is short-lived (15m) and stateless -- the server validates the signature without a database lookup. The trade-off: you cannot revoke an access token before expiry without a blocklist.
Refresh Token Rotation
Each refresh token is single-use. Exchanging it produces a new access + refresh pair and invalidates the old token. Reuse of an old token triggers revocation of the entire token family.
Token Family
A lineage of refresh tokens sharing a common ancestor. If any token in the family is replayed, the entire family is revoked -- bounding the damage window of a stolen token.
PKCE (Proof Key for Code Exchange)
OAuth2 extension (RFC 7636). The client generates a random code_verifier, sends its SHA-256 hash (code_challenge) in the authorization request, and proves possession by sending the original verifier when exchanging the authorization code.
CASL
Isomorphic authorization library for JavaScript. Defines abilities as can(action, subject, conditions) rules -- more expressive than simple role checks because it supports attribute-based conditions.
RBAC (Role-Based Access Control)
Permissions assigned to roles (admin, operator, viewer); users assigned roles. Simpler than ABAC but less granular. CASL bridges the gap by adding conditions to role-based rules.
Argon2id
Password hashing algorithm combining Argon2d (data-dependent memory access for GPU resistance) and Argon2i (data-independent access for side-channel resistance). OWASP recommended default since 2024.
Content Security Policy (CSP)
HTTP response header that restricts which sources the browser can load scripts, styles, images, and connections from. Primary defense against XSS.
HSTS (HTTP Strict Transport Security)
Header instructing the browser to only use HTTPS for the domain. With preload, the domain is hard-coded into browser HTTPS lists -- even the first visit is secure.
ValidationPipe
NestJS pipe that uses class-validator decorators to validate incoming request payloads. whitelist: true strips unknown properties; forbidNonWhitelisted: true rejects them.
API Key
Long-lived credential for server-to-server authentication. Stored as a SHA-256 hash in the database. The raw key is shown once at creation and cannot be recovered. Prefixed with ww_ for identification.
mTLS (Mutual TLS)
Both client and server present certificates during the TLS handshake. Used for service-to-service authentication in zero-trust networks.