Who Are You?: Implementing JWT Authentication in Your API Gateway
Hey, security squad! We've been on quite the journey so far. Our NestJS microservices are humming along, communicating efficiently with gRPC for synchronous calls and gracefully handling events with RabbitMQ for asynchronous flows. We even tamed data persistence with TypeORM and PostgreSQL. Everything's fantastic, right?
Well, almost. Imagine our users-service is happily dishing out user data, or our orders-service is processing purchases. But who's asking for this data? Is it anyone with the URL? In the real world, that's a recipe for disaster. We need to know who is making the request and whether they're allowed to do what they're trying to do.
This brings us to the crucial topic of authentication (who are you?) and authorization (what are you allowed to do?). In a monolithic application, this is often handled in one central place. But in a distributed microservice architecture, where you have multiple independent services, how do you manage security without creating a tangled mess or duplicating effort everywhere?
The Watchdog: Centralized Authentication at the API Gateway
While each microservice might eventually need to enforce its own fine-grained authorization rules, the most efficient and secure place to handle initial authentication is at the API Gateway. Remember our concierge analogy? The API Gateway is the first point of contact for external clients. It's the perfect place to act as the primary security checkpoint.
Here's why centralizing authentication at the API Gateway is a superpower:
- Single Point of Entry for Security: All external requests flow through the gateway. This means you only need to implement your core authentication logic once. No need to duplicate authentication code, configuration, and dependencies across every single microservice. This drastically reduces boilerplate and the potential for inconsistencies or security gaps.
- Reduced Load on Backend Services: Unauthenticated or invalid requests are rejected at the gateway level. This prevents unnecessary processing and resource consumption by your backend microservices, allowing them to focus solely on their core business logic.
- Consistent Security Policy: By centralizing, you ensure that all external-facing APIs adhere to the same authentication standards and policies. This simplifies auditing and maintenance.
- Simplified Client Interaction: Clients only need to know how to authenticate with the gateway. They don't need to worry about different authentication mechanisms for different backend services.
- Decoupling Services from Auth Logic: Your backend microservices can trust that any request reaching them via the gateway has already been authenticated. They might receive user context (like a user ID) from the gateway, but they don't need to perform the heavy lifting of token validation themselves. This keeps your microservices lean and focused.
For this part, we'll focus on implementing JWT (JSON Web Token) based authentication at our api-gateway.
The Passport: Understanding JSON Web Tokens (JWTs)
JWTs are a popular, compact, and self-contained way for securely transmitting information between parties as a JSON object. Think of a JWT as a digital passport. It contains information about the bearer (the user) and is signed to ensure its authenticity.
A JWT typically consists of three parts, separated by dots (.):
Header: Contains metadata about the token itself, usually the type of token (JWT) and the signing algorithm being used (e.g., HS256, RS256).
{ "alg": "HS256", "typ": "JWT" }Payload (Claims): This is the juicy part. It contains "claims" about the entity (typically the user) and additional data. There are different types of claims:
- Registered claims: Predefined claims like
iss(issuer),exp(expiration time),sub(subject, usually user ID). - Public claims: Custom claims that are publicly defined (e.g.,
name,email). - Private claims: Custom claims agreed upon by parties, but not publicly registered.
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022, // Issued At "exp": 1516242622 // Expiration Time }- Registered claims: Predefined claims like
Signature: This is what makes the JWT secure. It's created by taking the encoded Header, the encoded Payload, a secret key (known only to the server), and the algorithm specified in the header, and then hashing them.
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)The signature is used to verify that the token hasn't been tampered with and that it was issued by a trusted source.
How it works for authentication:
- Login: A user sends their credentials (username/password) to your authentication endpoint (e.g., on the API Gateway).
- Token Issuance: If the credentials are valid, the server (API Gateway) generates a JWT, signs it with a secret key, and sends it back to the client.
- Subsequent Requests: The client stores this JWT (e.g., in local storage or a cookie) and includes it in the
Authorizationheader of every subsequent request (usually asBearer <token>). - Token Validation: When the API Gateway receives a request with a JWT:
- It extracts the token from the header.
- It verifies the token's signature using the same secret key. If the signature is invalid, the token has been tampered with or wasn't issued by your server, and the request is rejected.
- If the signature is valid, it decodes the payload, extracts user information (like
subfor user ID), and checks for expiration. - If everything checks out, the request is allowed to proceed, and the extracted user information is typically attached to the request object for downstream use.
Pros of JWTs:
- Stateless: The server doesn't need to store session information. Each request carries its own authentication context, making it highly scalable for distributed systems.
- Compact & Self-Contained: They're small and contain all necessary information, reducing database lookups.
- Mobile-Friendly: Easily passed in HTTP headers.
Cons of JWTs:
- Token Invalidation: Once issued, a JWT is valid until it expires. Revoking a token before its natural expiration (e.g., on logout or security breach) requires additional mechanisms (like a blacklist or short expiration times with refresh tokens).
- Size: While compact, if you put too much data in the payload, the token can become large, impacting performance.
- Security: If the secret key is compromised, an attacker can forge tokens. Always keep your secret key secure!
Implementing JWT Authentication in NestJS (API Gateway)
NestJS integrates beautifully with Passport.js, a popular authentication middleware for Node.js. Passport uses "strategies" to handle different authentication mechanisms (JWT, OAuth, local username/password, etc.).
Let's get our hands dirty!
Step 1: Install Necessary Packages in api-gateway
Navigate into your apps/api-gateway directory and install the required packages:
cd apps/api-gateway
npm install @nestjs/passport passport passport-jwt @nestjs/jwt
# or yarn add @nestjs/passport passport passport-jwt @nestjs/jwt
npm install --save-dev @types/passport-jwt
# or yarn add --dev @types/passport-jwt
cd ../../ # Go back to the monorepo root@nestjs/passport: NestJS integration for Passport.passport: The core Passport.js library.passport-jwt: The Passport strategy specifically for JWT authentication.@nestjs/jwt: NestJS module for JWT token signing and verification.@types/passport-jwt: TypeScript type definitions forpassport-jwt.
Step 2: Create JWT Configuration (Secret Key)
We need a secret key to sign and verify our JWTs. Never hardcode this in production! For development, we'll put it directly in our JwtModule configuration. In a real app, you'd load this from environment variables.
Step 3: Create the AuthModule and JwtModule in api-gateway
Let's create a dedicated AuthModule within our api-gateway to encapsulate all authentication-related logic.
nx g @nx/nest:module auth --project=api-gatewayNow, open apps/api-gateway/src/app/auth/auth.module.ts and update it:
// apps/api-gateway/src/app/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service'; // We'll create this next
import { JwtStrategy } from './jwt.strategy'; // We'll create this next
import { AuthController } from './auth.controller'; // We'll create this next
@Module({
imports: [
PassportModule, // Provides Passport.js integration
JwtModule.register({
secret: 'superSecretKeyThatShouldBeInEnvVariables', // !!! IMPORTANT: Use a strong, environment-variable-loaded secret in production !!!
signOptions: { expiresIn: '60s' }, // Token expires in 60 seconds for testing
}),
],
providers: [AuthService, JwtStrategy], // Register our AuthService and JwtStrategy
controllers: [AuthController], // Register our AuthController for login endpoint
exports: [AuthService, JwtModule], // Export AuthService and JwtModule for use in other modules
})
export class AuthModule {}Explanation:
PassportModule: Initializes Passport.js for use in NestJS.JwtModule.register(): Configures the JWT module.secret: The secret key used to sign and verify tokens. Seriously, use an environment variable for this in production!signOptions: { expiresIn: '60s' }: Sets the expiration time for issued tokens. A short expiration is good for security (less time for a stolen token to be valid). In production, you'd combine this with refresh tokens for a better user experience.
providers: We declareAuthService(for login logic) andJwtStrategy(for token validation).controllers:AuthControllerwill contain our login endpoint.exports: We exportAuthServiceandJwtModuleso other modules (like ourAppModule) can use them.
Step 4: Create AuthService (Simplified Login)
For this tutorial, we'll simplify the login process. Instead of a real database check, we'll just check for a hardcoded username/password and then issue a token.
Create apps/api-gateway/src/app/auth/auth.service.ts:
// apps/api-gateway/src/app/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
// In a real app, you'd validate against a database
async validateUser(username: string, pass: string): Promise<any> {
// This is a mock validation. In production, you'd query your user database
// (e.g., through a gRPC call to a dedicated auth-service)
// and compare hashed passwords.
if (username === 'testuser' && pass === 'testpass') {
const user = { userId: 1, username: 'testuser', roles: ['user'] }; // Mock user data
return user;
}
return null;
}
// Generate a JWT for a validated user
async login(user: any) {
const payload = { username: user.username, sub: user.userId, roles: user.roles };
return {
access_token: this.jwtService.sign(payload), // Sign the payload to create the JWT
};
}
}Explanation:
JwtService: Injected from@nestjs/jwt, this service provides methods for signing (.sign()) and verifying (.verify()) JWTs.validateUser(): A simplified method to "validate" a user. In a real application, this would involve querying a database (or another microservice like a dedicatedauth-service) and comparing hashed passwords.login(): Takes a validated user object, creates a JWT payload (containing user ID, username, roles, etc.), and usesjwtService.sign()to create the actual JWT.
Step 5: Create JwtStrategy (Token Validation)
This is where Passport.js does its magic. The JwtStrategy will be responsible for extracting the JWT from the request and validating it.
Create apps/api-gateway/src/app/auth/jwt.strategy.ts:
// apps/api-gateway/src/app/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service'; // Our AuthService
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Extract token from Bearer header
ignoreExpiration: false, // Do not ignore token expiration
secretOrKey: 'superSecretKeyThatShouldBeInEnvVariables', // !!! IMPORTANT: Must match the secret in JwtModule.register() !!!
});
}
// This method is called after the token is extracted and verified
async validate(payload: any) {
// The payload is the decoded JWT payload
// You can perform additional validation here (e.g., check if user still exists in DB)
// For now, we just return the user object from the payload.
// This object will be attached to the request (req.user)
return { userId: payload.sub, username: payload.username, roles: payload.roles };
}
}Explanation:
extends PassportStrategy(Strategy): We extend theStrategyfrompassport-jwt.jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(): This tells the strategy to look for the JWT in theAuthorizationheader, prefixed withBearer.ignoreExpiration: false: We want the token to expire.secretOrKey: Must match the secret used inJwtModule.register()! This is used to verify the token's signature.validate(payload): This method is automatically called by Passport after it successfully extracts and verifies the JWT's signature and expiration. Thepayloadargument contains the decoded JWT payload. Whatever you return from this method will be attached to thereq.userobject in your controllers.
Step 6: Create AuthController (Login Endpoint)
This controller will expose our public login endpoint.
Create apps/api-gateway/src/app/auth/auth.controller.ts:
// apps/api-gateway/src/app/auth/auth.controller.ts
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
interface LoginDto {
username: string;
password: string;
}
@Controller('auth') // Base path for authentication endpoints
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser(loginDto.username, loginDto.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return this.authService.login(user); // Return the JWT
}
}Explanation:
@Controller('auth'): All endpoints in this controller will be prefixed with/auth.@Post('login'): Defines a POST endpoint at/auth/login.authService.validateUser(): Calls our service to "validate" the user.authService.login(): If valid, generates and returns the JWT.UnauthorizedException: NestJS's built-in exception for unauthorized access.
Step 7: Integrate AuthModule into AppModule
Now, we need to import our new AuthModule into the api-gateway's main AppModule.
Open apps/api-gateway/src/app/app.module.ts and update it:
// apps/api-gateway/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module'; // Import AuthModule
@Module({
imports: [
AuthModule, // Import our new AuthModule
ClientsModule.register([
{
name: 'USERS_SERVICE',
transport: Transport.GRPC,
options: {
package: 'users',
protoPath: join(__dirname, '../..', 'libs/proto/users.proto'),
url: '127.0.0.1:3001',
},
},
]),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}Step 8: Protect an Endpoint with AuthGuard
Finally, let's protect our GET /users endpoint in api-gateway/src/app/app.controller.ts so that only authenticated users can access it.
// apps/api-gateway/src/app/app.controller.ts
import { Controller, Get, Post, Body, Inject, Param, Put, Delete, HttpCode, HttpStatus, UseGuards, Request } from '@nestjs/common'; // Add UseGuards, Request
import { ClientProxy } from '@nestjs/microservices';
import { AppService } from './app.service';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthGuard } from '@nestjs/passport'; // Import AuthGuard
// Define the expected types for our gRPC messages (matching .proto)
interface User { id: number; name: string; email: string; }
interface UsersResponse { users: User[]; }
interface CreateUserRequest { name: string; email: string; }
interface GetUserByIdRequest { id: number; }
interface UpdateUserRequest { id: number; name?: string; email?: string; }
interface DeleteUserRequest { id: number; }
interface DeleteUserResponse { success: boolean; }
@Controller('users')
export class AppController {
constructor(
private readonly appService: AppService,
@Inject('USERS_SERVICE') private readonly usersServiceClient: ClientProxy,
) {}
// Protect this endpoint with JwtAuthGuard
@UseGuards(AuthGuard('jwt')) // 'jwt' refers to the JwtStrategy we created
@Get() // GET /users
getUsers(@Request() req): Observable<UsersResponse> { // Inject Request object to access user info
console.log(`API Gateway received HTTP GET request for /users from user: ${req.user.username}`);
// You can now access req.user.userId, req.user.roles, etc.
// In a real scenario, you might pass req.user.userId downstream to the users-service
// to filter results or perform authorization.
return this.usersServiceClient.send<UsersResponse>('GetUsers', {});
}
@UseGuards(AuthGuard('jwt')) // Protect other CRUD endpoints too!
@Get(':id') // GET /users/:id
getUserById(@Param('id') id: string, @Request() req): Observable<User> {
console.log(`API Gateway received HTTP GET request for /users/${id} from user: ${req.user.username}`);
return this.usersServiceClient.send<User>('GetUserById', { id: parseInt(id, 10) });
}
@UseGuards(AuthGuard('jwt'))
@Post() // POST /users
createUser(@Body() user: CreateUserRequest, @Request() req): Observable<User> {
console.log(`API Gateway received HTTP POST request for /users from user: ${req.user.username}`);
return this.usersServiceClient.send<User>('CreateUser', user);
}
@UseGuards(AuthGuard('jwt'))
@Put(':id') // PUT /users/:id
updateUser(@Param('id') id: string, @Body() user: { name?: string; email?: string }, @Request() req): Observable<User> {
console.log(`API Gateway received HTTP PUT request for /users/${id} from user: ${req.user.username}`);
const updatePayload: UpdateUserRequest = {
id: parseInt(id, 10),
name: user.name,
email: user.email,
};
return this.usersServiceClient.send<User>('UpdateUser', updatePayload);
}
@UseGuards(AuthGuard('jwt'))
@Delete(':id') // DELETE /users/:id
@HttpCode(HttpStatus.NO_CONTENT)
deleteUser(@Param('id') id: string, @Request() req): Observable<void> {
console.log(`API Gateway received HTTP DELETE request for /users/${id} from user: ${req.user.username}`);
return this.usersServiceClient.send<DeleteUserResponse>('DeleteUser', { id: parseInt(id, 10) }).pipe(
map(() => undefined)
);
}
}Explanation:
@UseGuards(AuthGuard('jwt')): This decorator applies theJwtAuthGuardto thegetUsersmethod (and any other methods you add it to). When a request comes in, Passport.js will automatically try to validate the JWT using ourJwtStrategy.@Request() req: By injecting theRequestobject, we can accessreq.user, which will contain theuserId,username, androlesreturned by ourJwtStrategy'svalidatemethod. This is how the API Gateway gets the authenticated user's context.
Time to Test Our Secure API!
You'll need four terminal windows open at the root of your nestjs-ms-blueprint monorepo (plus your Docker container running).
- Terminal 1 (for Docker PostgreSQL): Ensure it's running (
docker ps). Terminal 2 (for
users-service):nx serve users-serviceTerminal 3 (for
notifications-service):nx serve notifications-serviceTerminal 4 (for
api-gateway):nx serve api-gateway
Now, open Postman or Insomnia.
1. Attempt to Access Protected Endpoint (Unauthorized)
- Method:
GET - URL:
http://localhost:3000/users - Expected Response:
401 Unauthorized. This is great! Our guard is working.
2. Login to Get a JWT
- Method:
POST - URL:
http://localhost:3000/auth/login Body (raw JSON):
{ "username": "testuser", "password": "testpass" }- Expected Response:
201 Createdwith a JSON object containing anaccess_token. Copy this token! It will look like a long string of characters.
3. Access Protected Endpoint with JWT
- Method:
GET - URL:
http://localhost:3000/users - Headers: Add an
Authorizationheader with the valueBearer YOUR_ACCESS_TOKEN_HERE(replaceYOUR_ACCESS_TOKEN_HEREwith the token you copied). - Expected Response:
200 OKwith the list of users. - Terminal Logs: Check the
api-gatewayterminal. You should see theconsole.logmessage indicating the request came fromtestuser.
4. Test Token Expiration
- Wait for 60 seconds (the
expiresIntime we set). - Try the
GET /usersrequest again with the same token. - Expected Response:
401 Unauthorized. This shows the token has expired, and our security is working as expected.
Passing User Context Downstream (Briefly)
Right now, our api-gateway validates the user, but the users-service doesn't actually know who made the request. In many scenarios, the downstream services need this context (e.g., "only show users created by this user," or "log this user's action").
There are a few ways to pass this context:
- Custom HTTP Headers: The API Gateway can add custom headers (e.g.,
X-User-ID,X-User-Roles) to the gRPC request before forwarding it. The downstream service would then read these headers. - Modifying Payloads: For gRPC, you could extend your Protobuf messages to include an
authenticated_user_idfield that the gateway populates. This is cleaner and type-safe. - gRPC Metadata: gRPC has a concept of metadata (key-value pairs) that can be attached to requests. This is a very clean way to pass authentication and authorization context.
We won't implement the downstream passing in this part to keep focus on gateway authentication, but keep this in mind as you build more complex microservices!
Wrapping Up Part 6
Fantastic job! You've just implemented centralized JWT authentication at your NestJS API Gateway. This is a monumental step in securing your microservice architecture. You've learned:
- The critical role of the API Gateway in handling security.
- The structure and flow of JSON Web Tokens (JWTs) for stateless authentication.
- How to integrate Passport.js and JWT Strategy into NestJS.
- How to generate JWTs upon successful login.
- How to protect API endpoints using NestJS Guards and access authenticated user information.
Your system is now much more secure, ensuring that only legitimate users can access your protected resources. This sets the stage for more advanced authorization logic down the line.
Finally, we're almost ready for prime time! All our services are built, talking, storing data, and now they're secure. But how do we package them up and run them all together, easily, for deployment?
Get ready for Part 7: Ready for Launch: How to Dockerize Your Entire NestJS Microservices App! We'll write Dockerfiles and a docker-compose.yml to spin up our entire ecosystem with a single command.
See you there!
