NestJS Authentication with Keycloak: Complete Guide

Guilliano Molaire Guilliano Molaire Updated March 24, 2026 9 min read

Last updated: March 2026

NestJS is a natural pairing for Keycloak. Both are enterprise-oriented, both embrace modular architecture, and together they give you a TypeScript API with single sign-on, role-based access control, and multi-factor authentication without building any of the identity plumbing yourself.

This guide covers two approaches to integrating Keycloak with NestJS:

  1. nest-keycloak-connect — a purpose-built Keycloak module with guards and decorators
  2. Custom JWT validation using @nestjs/passport and passport-jwt — for teams that want more control

Both approaches produce the same result: a NestJS API where routes are protected by Keycloak-issued JWTs, and access decisions are made based on realm and client roles.

Prerequisites

Step 1: Configure the Keycloak Client

In the Keycloak Admin Console:

  1. Go to Clients > Create client
  2. Set Client ID to nestjs-api
  3. Set Client type to OpenID Connect
  4. Enable Client authentication (confidential client)
  5. Enable Service accounts roles (for backend-to-backend auth)
  6. Set Valid redirect URIs to http://localhost:3000/*
  7. Set Web origins to http://localhost:3000

After saving, copy the Client secret from the Credentials tab.

Create Roles

Create the following client roles:

  • user — basic access
  • admin — administrative access

Assign them to test users via Users > Role mappings > Client roles.

For more on role configuration, see our RBAC overview and the post on fine-grained authorization in Keycloak.

Approach 1: nest-keycloak-connect

This is the fastest path to Keycloak integration in NestJS. The nest-keycloak-connect package provides guards, decorators, and a module that handles most of the wiring for you.

Install Dependencies

npm install nest-keycloak-connect keycloak-connect

Your package.json dependencies should include:

{
  "dependencies": {
    "@nestjs/common": "^10.4.0",
    "@nestjs/core": "^10.4.0",
    "@nestjs/platform-express": "^10.4.0",
    "keycloak-connect": "^26.0.0",
    "nest-keycloak-connect": "^1.10.0",
    "reflect-metadata": "^0.2.0",
    "rxjs": "^7.8.0"
  }
}

Module Configuration

Register the Keycloak module in your AppModule:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
  KeycloakConnectModule,
  ResourceGuard,
  RoleGuard,
  AuthGuard,
  PolicyEnforcementMode,
  TokenValidation,
} from 'nest-keycloak-connect';
import { APP_GUARD } from '@nestjs/core';
import { ItemsModule } from './items/items.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    KeycloakConnectModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        authServerUrl: config.get('KEYCLOAK_URL', 'http://localhost:8080'),
        realm: config.get('KEYCLOAK_REALM', 'your-realm'),
        clientId: config.get('KEYCLOAK_CLIENT_ID', 'nestjs-api'),
        secret: config.get('KEYCLOAK_CLIENT_SECRET', ''),
        policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
        tokenValidation: TokenValidation.ONLINE,
      }),
    }),
    ItemsModule,
    UsersModule,
  ],
  providers: [
    // Apply guards globally in this order
    { provide: APP_GUARD, useClass: AuthGuard },
    { provide: APP_GUARD, useClass: ResourceGuard },
    { provide: APP_GUARD, useClass: RoleGuard },
  ],
})
export class AppModule {}

Key configuration options:

  • PolicyEnforcementMode.PERMISSIVE: Allows access to routes without explicit role requirements. Use ENFORCING if you want all routes protected by default.
  • TokenValidation.ONLINE: Validates tokens against Keycloak on every request (most secure). Use OFFLINE for local JWT validation (faster).

Create Environment Variables

# .env
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=nestjs-api
KEYCLOAK_CLIENT_SECRET=your-client-secret

Protected Controllers

// items/items.controller.ts
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import {
  Roles,
  Public,
  AuthenticatedUser,
  Unprotected,
} from 'nest-keycloak-connect';

interface KeycloakUser {
  sub: string;
  email: string;
  preferred_username: string;
  realm_access: { roles: string[] };
  resource_access: Record<string, { roles: string[] }>;
}

interface Item {
  id: number;
  name: string;
  owner: string;
}

@Controller('items')
export class ItemsController {
  private items: Item[] = [
    { id: 1, name: 'Widget', owner: 'user1' },
    { id: 2, name: 'Gadget', owner: 'user2' },
  ];

  @Get()
  @Roles({ roles: ['user', 'admin'] })
  findAll(@AuthenticatedUser() user: KeycloakUser): Item[] {
    return this.items;
  }

  @Get(':id')
  @Roles({ roles: ['user', 'admin'] })
  findOne(
    @Param('id') id: string,
    @AuthenticatedUser() user: KeycloakUser,
  ): Item | { error: string } {
    const item = this.items.find((i) => i.id === parseInt(id));
    if (!item) {
      return { error: 'Item not found' };
    }
    return item;
  }

  @Post()
  @Roles({ roles: ['admin'] })
  create(
    @Body() body: { name: string },
    @AuthenticatedUser() user: KeycloakUser,
  ): Item {
    const newItem: Item = {
      id: this.items.length + 1,
      name: body.name,
      owner: user.preferred_username,
    };
    this.items.push(newItem);
    return newItem;
  }

  @Delete(':id')
  @Roles({ roles: ['admin'] })
  remove(@Param('id') id: string): { deleted: boolean } {
    this.items = this.items.filter((i) => i.id !== parseInt(id));
    return { deleted: true };
  }
}
// items/items.module.ts
import { Module } from '@nestjs/common';
import { ItemsController } from './items.controller';

@Module({
  controllers: [ItemsController],
})
export class ItemsModule {}

The @Roles decorator accepts client roles by default. The @AuthenticatedUser() parameter decorator injects the decoded Keycloak token, giving you access to all JWT claims.

Public Routes

Use the @Public() or @Unprotected() decorator for routes that should be accessible without authentication:

// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Public } from 'nest-keycloak-connect';

@Controller('health')
export class HealthController {
  @Get()
  @Public()
  check() {
    return { status: 'healthy', timestamp: new Date().toISOString() };
  }
}

Approach 2: Custom JWT Validation with Passport

For teams that want more control over the authentication flow or need to integrate with multiple identity providers, here’s a custom approach using @nestjs/passport and passport-jwt:

Install Dependencies

npm install @nestjs/passport passport passport-jwt jwks-rsa
npm install -D @types/passport-jwt

JWT Strategy

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

export interface KeycloakTokenPayload {
  sub: string;
  email: string;
  preferred_username: string;
  realm_access: {
    roles: string[];
  };
  resource_access: Record<
    string,
    {
      roles: string[];
    }
  >;
  scope: string;
  iss: string;
  aud: string | string[];
}

@Injectable()
export class KeycloakJwtStrategy extends PassportStrategy(
  Strategy,
  'keycloak',
) {
  constructor(private configService: ConfigService) {
    const keycloakUrl = configService.get('KEYCLOAK_URL');
    const realm = configService.get('KEYCLOAK_REALM');
    const issuer = `${keycloakUrl}/realms/${realm}`;

    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      issuer: issuer,
      algorithms: ['RS256'],
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 10,
        jwksUri: `${issuer}/protocol/openid-connect/certs`,
      }),
    });
  }

  validate(payload: KeycloakTokenPayload): KeycloakTokenPayload {
    // The payload is the decoded JWT. passport-jwt has already
    // verified the signature, expiration, and issuer.
    return payload;
  }
}

The passportJwtSecret function from jwks-rsa automatically fetches and caches Keycloak’s public keys from the JWKS endpoint. The rate limiter prevents excessive key requests.

Auth Module

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { KeycloakJwtStrategy } from './keycloak-jwt.strategy';

@Module({
  imports: [PassportModule.register({ defaultStrategy: 'keycloak' })],
  providers: [KeycloakJwtStrategy],
  exports: [PassportModule],
})
export class AuthModule {}

Custom Guards

// auth/guards/keycloak-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class KeycloakAuthGuard extends AuthGuard('keycloak') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}
// auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class KeycloakRolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );

    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      return false;
    }

    const realmRoles = user.realm_access?.roles || [];
    const clientRoles = Object.values(user.resource_access || {}).flatMap(
      (resource: any) => resource.roles || [],
    );
    const allRoles = new Set([...realmRoles, ...clientRoles]);

    return requiredRoles.some((role) => allRoles.has(role));
  }
}

Custom Decorators

// auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);

Using the Custom Approach

// app.module.ts (custom approach)
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { KeycloakAuthGuard } from './auth/guards/keycloak-auth.guard';
import { KeycloakRolesGuard } from './auth/guards/roles.guard';
import { ItemsModule } from './items/items.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    AuthModule,
    ItemsModule,
  ],
  providers: [
    { provide: APP_GUARD, useClass: KeycloakAuthGuard },
    { provide: APP_GUARD, useClass: KeycloakRolesGuard },
  ],
})
export class AppModule {}
// items/items.controller.ts (custom approach)
import { Controller, Get, Post, Body, Delete, Param } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { KeycloakTokenPayload } from '../auth/keycloak-jwt.strategy';

@Controller('items')
export class ItemsController {
  @Get()
  @Roles('user', 'admin')
  findAll(@CurrentUser() user: KeycloakTokenPayload) {
    return {
      items: [
        { id: 1, name: 'Widget', owner: user.preferred_username },
        { id: 2, name: 'Gadget', owner: user.preferred_username },
      ],
    };
  }

  @Post()
  @Roles('admin')
  create(
    @Body() body: { name: string },
    @CurrentUser('preferred_username') username: string,
  ) {
    return { id: 3, name: body.name, owner: username };
  }

  @Delete(':id')
  @Roles('admin')
  remove(@Param('id') id: string) {
    return { deleted: true, id: parseInt(id) };
  }
}

Which Approach Should You Choose?

Criteria nest-keycloak-connect Custom Passport
Setup time Faster More initial work
Customization Limited to package API Full control
Multiple IdPs Keycloak only Any OIDC provider
Maintenance Depends on package updates You own it
Online validation Built-in Requires custom code
Learning curve Lower Moderate

Use nest-keycloak-connect if Keycloak is your only identity provider and you want rapid setup. Use the custom Passport approach if you need flexibility, plan to support multiple providers, or want to avoid a dependency on a third-party NestJS package.

GraphQL Integration

If you’re using @nestjs/graphql, both approaches work with GraphQL resolvers. Here’s an example using the custom decorators:

// items/items.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { KeycloakTokenPayload } from '../auth/keycloak-jwt.strategy';

@Resolver('Item')
export class ItemsResolver {
  @Query('items')
  @Roles('user', 'admin')
  findAll(@CurrentUser() user: KeycloakTokenPayload) {
    return [
      { id: 1, name: 'Widget', owner: user.preferred_username },
      { id: 2, name: 'Gadget', owner: user.preferred_username },
    ];
  }

  @Mutation('createItem')
  @Roles('admin')
  create(
    @Args('name') name: string,
    @CurrentUser() user: KeycloakTokenPayload,
  ) {
    return { id: 3, name, owner: user.preferred_username };
  }
}

For GraphQL, the guards and decorators work identically to REST controllers because NestJS uses the same execution context under the hood. The @CurrentUser() decorator extracts the user from the request regardless of the transport layer.

Run and Test

npm run start:dev

Get a Token

TOKEN=$(curl -s -X POST 
  http://localhost:8080/realms/your-realm/protocol/openid-connect/token 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "client_id=nestjs-api" 
  -d "client_secret=your-client-secret" 
  -d "grant_type=password" 
  -d "username=testuser" 
  -d "password=testpassword" 
  | jq -r '.access_token')

Test Endpoints

# Health check (public)
curl http://localhost:3000/health

# List items (requires 'user' or 'admin' role)
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/items

# Create item (requires 'admin' role)
curl -X POST -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: application/json" 
  -d '{"name": "New Item"}' 
  http://localhost:3000/items

Use our JWT Token Analyzer to decode the access token and verify that realm and client roles are present. If roles are missing, check the client scope mappers in Keycloak — make sure “Add to access token” is enabled for the roles mapper.

Production Security Checklist

Before deploying to production, address these items:

  1. Use HTTPS everywhere. Configure TLS between your NestJS app, Keycloak, and all clients. If running Keycloak behind a reverse proxy, see our guide on running Keycloak behind a reverse proxy.

  2. Set short access token lifetimes. Configure 5-15 minute access tokens in Keycloak. Use refresh tokens for longer sessions.

  3. Enable audit logging. Track authentication events in Keycloak for compliance and security monitoring. Our guide on auditing in Keycloak explains how.

  4. Enforce MFA for admin users. Keycloak supports conditional MFA based on roles or application context.

  5. Validate the audience claim. Ensure your JWT validation checks the aud claim to prevent tokens issued for other clients from being accepted.

  6. Monitor session activity. Use Keycloak’s session management to track and terminate active sessions when needed.

  7. Rate-limit authentication endpoints. Protect against brute force attacks on your token-issuing endpoints.

What’s Next

With your NestJS API secured by Keycloak, consider these extensions:


Let us handle Keycloak for you. Skycloak provides fully managed Keycloak with automated scaling, security patches, and 99.99% uptime SLA. See pricing or explore our security practices.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman