Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

JWT Authentication and Role-Based Access Control in LocalHands

Subtitle: How every request crossing the LocalHands API gets authenticated and role-checked before it touches any business logic. 1. The Problem This Solves LocalHands has three user roles - CLIENT, PROVIDER, and ADMIN

Subtitle: How every request crossing the LocalHands API gets authenticated and role-checked before it touches any business logic.

1. The Problem This Solves
LocalHands has three user roles - CLIENT, PROVIDER, and ADMIN - each with a different set of permitted actions. A Client can post a Service Order. A Provider submits Proposals. An Admin manages verification. None of those actions should cross role boundaries, and no action should be possible without a verified identity behind it.
This article walks through exactly how that enforcement is implemented, end to end, using the real code from the LocalHands backend.

2. The Login DTO - What the API Accepts
Every login request is validated against a typed DTO before it reaches any business logic:

// src/auth/dto/login.dto.ts
export class LoginDto {
  @IsString()
  @IsNotEmpty()
  identifier!: string; // accepts email OR phone number

  @IsString()
  @IsNotEmpty({ message: 'Password is required' })
  @Length(8, 100, { message: 'Password must be at least 8 characters long' })
  password!: string;
}

The identifier field is the first concrete HCI decision in the auth layer: it accepts either an email address or a phone number. In a market where phone numbers are tied to MTN MoMo and Orange Money accounts and often serve as primary identity, forcing email-only login would exclude a meaningful share of users before they even get past the login screen.

3. The Authentication Flow - AuthService

// src/auth/auth.service.ts (validateUser)
async validateUser(
  identifier: string,
  password: string,
): Promise<Omit<User, 'passwordHash'> | null> {
  let user: User;
  try {
    if (identifier.includes('@')) {
      user = await this.usersService.findByEmail(identifier);
    } else {
      user = await this.usersService.findByPhoneNumber(identifier);
    }
  } catch (error) {
    return null;
  }

  if (user && (await bcrypt.compare(password, user.passwordHash))) {
    const { passwordHash: _, ...result } = user;
    return result;
  }
  return null;
}

Three things worth naming here:
Identifier routing - identifier.includes('@') routes the lookup to email or phone number. Simple, effective, and requires no additional field from the user.
bcrypt.compare() - passwords are never stored in plaintext. The stored value is a hash; bcrypt.compare() checks the submitted password against it without ever reconstructing the original.
passwordHash is stripped before return: - const { passwordHash: _, ...result } = user means the hash never travels beyond this function.
It does not appear in the JWT payload, it does not appear in the response body, it does not leave the auth layer.

// src/auth/auth.service.ts (login)
async login(loginDto: LoginDto) {
  const validatedUser = await this.validateUser(identifier, password);
  if (!validatedUser) {
    throw new ForbiddenException('Invalid credentials');
  }
  await this.usersService.updateLastLogin(validatedUser.id);

  const payload = {
    email: validatedUser.email,
    phoneNumber: validatedUser.phoneNumber,
    sub: validatedUser.id,
    name: validatedUser.name,
    role: validatedUser.role,
  };

  return {
    access_token: this.jwtService.sign(payload),
    user: validatedUser,
  };
}

updateLastLogin() fires on every successful login, a timestamped audit trail built into the authentication flow rather than fixed on later.
The JWT payload carries exactly five fields:email, phoneNumber, sub (the user's ID), name, and role.
The role field is what every downstream guard reads to make authorization decisions.

4. The JWT Strategy - Token Validation

// src/auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.get<string>('JWT_SECRET'),
    });
  }

  validate(payload: JwtPayload) {
    return {
      id: payload.sub,
      email: payload.email,
      name: payload.name,
      role: payload.role,
      phoneNumber: payload.phoneNumber,
    };
  }
}

ignoreExpiration: false means an expired token is rejected outright - no grace period, no silent acceptance. ExtractJwt.fromAuthHeaderAsBearerToken() means the token must travel in the Authorization header as a Bearer token, not in a cookie or query string.
The validate() method's return value is what gets attached to request.user, the object that every controller and guard downstream reads when it needs to know who is making the request.

5. The Guards - JwtAuthGuard and RoleGuard

// src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// src/auth/guards/role.guard.ts
@Injectable()
export class RoleGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<string[]>(
      'roles',
      context.getHandler(),
    );
    if (!requiredRoles) {
      return true; // no roles required - allow through
    }
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user.role);
  }
}

JwtAuthGuard is a single-line extension of Passport's AuthGuard('jwt').
It delegates all token validation to the JWT strategy and blocks the request with a 401 if validation fails.
RoleGuard reads the metadata set by a @Roles() decorator on the route handler, pulls user.role from the request object that JwtAuthGuard already populated, and checks whether the user's role is in the required list.
If no roles are required on a route,RoleGuard passes the request through without checking.
The guard order matters:

flowchart LR
    A["๐Ÿ“ค Request<br/>Authorization: Bearer &lt;token&gt;"] --> B["๐Ÿ”’ JwtAuthGuard"]
    B --> C["JwtStrategy.validate()"]
    C --> D["Attach user to<br/>Request object"]
    D --> E["๐ŸŽฎ Controller<br/>accesses req.user"]

    style B fill:#e1f5fe
    style D fill:#c8e6c9

JwtAuthGuard always runs first because RoleGuard reads request. user- an object that only exists after JwtAuthGuard has validated the token and called JwtStrategy.validate(). If the order were reversed, RoleGuard would try to read a user object that does not exist yet.

6. Guards in Action - The Proposal Controller

// src/proposal/proposal.controller.ts
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new proposal' })
create(@Body() createProposalDto: CreateProposalDto) {
  return this.proposalService.create(createProposalDto);
}

Every protected route in the Proposal controller carries @UseGuards(JwtAuthGuard) and @ApiBearerAuth(). The @ApiBearerAuth() decorator is what tells Swagger UI to show the padlock icon and the token input field at /api/docs - it does not enforce anything at runtime, but it makes the auth requirement visible to anyone testing the API.
@HttpCode(HttpStatus.CREATED) sets the success response to 201. The @ApiResponsedecorators document the possible error states: 401 for a missing/invalid token, 404 for a service or provider that does not exist. These are not just documentation - they map to real exceptions thrown inside the service layer.

7. The Full Request Pipeline
Putting it together, here is what every authenticated request travels through:

POST /proposal (with Authorization: Bearer <token>)

โ†’ Global ValidationPipe
    whitelist: true              // strips unknown fields from body
    transform: true              // casts body to CreateProposalDto
    forbidNonWhitelisted: true   // rejects unexpected fields

โ†’ JwtAuthGuard
    ExtractJwt.fromAuthHeaderAsBearerToken()
    ignoreExpiration: false
    JwtStrategy.validate() โ†’ attaches user to request

โ†’ RoleGuard (if @Roles() decorator present on handler)
    reads request.user.role
    checks against required roles

โ†’ ProposalController.create()
    receives validated DTO + authenticated user

โ†’ ProposalService.create()
    business logic + Prisma query

โ†’ PostgreSQL

Without clearing, nothing reaches ProposalController without clearing JwtAuthGuard. Nothing reaches ProposalService without clearing the controller's DTO validation. The pipeline is linear; each layer depends on the one above it passing cleanly.

๐Ÿ’ก For a full breakdown of the system architecture and tech stack decisions behind LocalHands, check out my article on Medium.ย 

What's Next
With authentication and authorization in place, the next article covers the entire trust model, which centers on the Escrow Architecture. Learn how LocalHands programmatically locks a Client's payment at contract formation, securely holds it through the work period, and triggers the release to the Provider based on verified completion protocols.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.