Protect Resources with Ory Keto in NestJS

January 13, 2026 Updated: January 14, 2026 Rens Jaspers NestJS TypeScript Webdev Security Authorization Ory Keto

Authorization is hard to get right, especially if you want good control without making things complex. Ory Keto is a great solution for this problem.

It uses ReBAC (Relationship-Based Access Control). You store facts as relationships (relation tuples), like "user X is an owner of video Y". Then you write a small permission model in Ory Permission Language (OPL) that says which relationships grant "view", "edit", and so on. When you check access, Keto combines the stored relationships with that model and returns "allowed" or "denied".

In this tutorial, we connect Ory Keto to NestJS in a clean way. We build a small authorization layer around Keto, so the rest of your app stays simple and easy to test.

Let's start with what we're building. The core is an AuthorizationService that both your HTTP layer (via guards) and business layer (via services) use to check permissions and manage relationships.

Here's how controllers will look:

@Controller("videos")
@UseGuards(AuthenticationGuard, AuthorizationGuard)
export class VideoController {
  @Get(":id")
  @Authorize({
    namespace: "Video",
    relation: "view",
    resourceId: (req) => req.params.id,
  })
  getVideo() {
    // ...
  }
}

Each endpoint declares what it protects. The AuthorizationGuard uses the AuthorizationService under the hood.

And here's how services use the same AuthorizationService:

@Injectable()
export class VideoService {
  constructor(
    private readonly videoRepository: VideoRepository,
    private readonly authorizationService: AuthorizationService,
  ) {}

  async create(dto: CreateVideoDto, ownerId: string): Promise<Video> {
    const video = await this.videoRepository.create(dto);

    await this.authorizationService.createRelation({
      namespace: "Video",
      object: video.id,
      relation: "owners",
      subjectId: ownerId,
    });

    return video;
  }
}

Note: In a real app, you usually want to create the entity and grant the related permission in one step. A common pattern is an outbox table. In the same database transaction, you save the entity and also store a message like “grant this permission”. A background worker later syncs that message to Keto. This is a separate topic, so it is out of scope for this article.

Let's build this step by step.

Phase 0 – Architecture

We'll wrap the Ory Keto SDK in our own AuthorizationService. This keeps our code simple and testable. Controllers and services call AuthorizationService, not Keto directly.

For HTTP endpoints, we'll build an AuthorizationGuard with an @Authorize decorator. The guard blocks unauthorized requests before they reach your route handlers.

Note: If you use multiple transports (HTTP, queues, jobs, GraphQL), add authorization checks in your service layer too.

Phase 1 – NestJS abstractions

In this phase, we build the authorization system structure without Ory Keto. We'll connect Keto later.

1.1 Fake Authentication Module

We need authentication to test authorization. For this tutorial, we'll use a simple fake auth service—no JWTs, sessions, or OAuth. Just a guard that attaches a user to the request.

Generate a module: nest g module authentication.

// interfaces/user.interface.ts
export interface User {
  id: string;
  username: string;
  email: string;
}
// authentication/authentication.service.ts
@Injectable()
export class AuthenticationService {
  /**
   * Demo only. In production, verify the token/session
   * with your identity provider (e.g. Ory Kratos).
   */
  validateUser(_authHeader: string | undefined): User | null {
    return {
      id: "user-123",
      username: "demo-user",
      email: "demo@example.com",
    };
  }
}
// authentication/authentication.guard.ts
@Injectable()
export class AuthenticationGuard implements CanActivate {
  constructor(private readonly authenticationService: AuthenticationService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = this.authenticationService.validateUser(request.headers.authorization);

    if (!user) {
      throw new UnauthorizedException("Invalid or missing authentication");
    }

    request.user = user;
    return true;
  }
}

The guard validates the header and attaches the user to the request. This user is then available to other guards and route handlers.

1.2 Authorization Module

This module defines what authorization does, not how it does it.

Generate a module: nest g module authorization.

We'll start with interfaces. The AuthorizationService defines methods for checking permissions and managing relationships. For now, these throw NotImplementedException. We'll implement them with Keto later.

// authorization/interfaces/relation-tuple.interface.ts
import { Request } from "express";

export interface RelationTupleDefinition {
  namespace: string;
  relation: string;
  resourceId: (request: Request) => string;
}

export interface ResolvedRelationTuple {
  namespace: string;
  object: string;
  relation: string;
  subjectId: string;
}

A relation tuple follows Keto's format: namespace:object#relation@subject. Example: Video:video-123#view@user-456 means "user-456 can view video-123".

// authorization/authorization.service.ts
@Injectable()
export class AuthorizationService {
  async checkPermission(tuple: ResolvedRelationTuple): Promise<boolean> {
    throw new NotImplementedException("Not implemented yet");
  }

  async createRelation(tuple: ResolvedRelationTuple): Promise<void> {
    throw new NotImplementedException("Not implemented yet");
  }

  async deleteRelation(tuple: ResolvedRelationTuple): Promise<void> {
    throw new NotImplementedException("Not implemented yet");
  }
}

This is the core abstraction. The rest of your app depends on this interface, not on Keto directly.

1.3 Authorization Guard

The guard enforces authorization at runtime. It reads metadata from the route handler and calls AuthorizationService to check permissions.

// authorization/authorization.guard.ts
@Injectable()
export class AuthorizationGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly authorizationService: AuthorizationService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const definition = this.reflector.get<RelationTupleDefinition>(AUTHORIZE_KEY, context.getHandler());

    if (!definition) {
      return true;
    }

    const request = context.switchToHttp().getRequest<Request>();
    if (!request.user) {
      throw new ForbiddenException("User not authenticated");
    }

    const resourceId = definition.resourceId(request);
    const isAllowed = await this.authorizationService.checkPermission({
      namespace: definition.namespace,
      object: resourceId,
      relation: definition.relation,
      subjectId: request.user.id,
    });

    if (!isAllowed) {
      throw new ForbiddenException(`Missing ${definition.relation} permission on ${definition.namespace}:${resourceId}`);
    }

    return true;
  }
}

This keeps controllers clean. They declare what they need, not how to check it.

1.4 Authorization Decorator

The decorator stores authorization metadata on route handlers. It accepts a resource ID getter function that extracts the ID from the request.

// authorization/decorators/authorize.decorator.ts
export const AUTHORIZE_KEY = "authorize";

export const Authorize = (definition: RelationTupleDefinition) => SetMetadata(AUTHORIZE_KEY, definition);

1.5 Resource ID Helpers

These helpers extract resource IDs from common request locations.

// authorization/helpers/resource-id.helpers.ts
import { Request } from "express";

type ResourceIdGetter = (request: Request) => string;

export const fromParam =
  (key: string): ResourceIdGetter =>
  (req) =>
    req.params[key];

export const fromBody =
  (key: string): ResourceIdGetter =>
  (req) =>
    req.body[key];

export const fromQuery =
  (key: string): ResourceIdGetter =>
  (req) =>
    req.query[key] as string;

You can write custom getters for complex scenarios too.

1.6 Constants

We use constants to avoid typos. A typo in a namespace or relation name breaks authorization silently—the code compiles, but permissions fail at runtime.

// authorization/authorization.constants.ts
export const VIDEO_NAMESPACE = "Video";
export const USER_NAMESPACE = "User";

export const VIDEO_RELATIONS = {
  owners: "owners",
  editors: "editors",
  viewers: "viewers",
} as const;

export const VIDEO_PERMISSIONS = {
  view: "view",
  edit: "edit",
  delete: "delete",
} as const;

1.7 Example: Video Controller

Now we can protect endpoints. We apply both guards: AuthenticationGuard (sets the user) and AuthorizationGuard (checks permissions).

// video/video.controller.ts
@Controller("videos")
@UseGuards(AuthenticationGuard, AuthorizationGuard)
export class VideoController {
  constructor(private readonly videoService: VideoService) {}

  @Get(":id")
  @Authorize({
    namespace: VIDEO_NAMESPACE,
    relation: VIDEO_PERMISSIONS.view,
    resourceId: fromParam("id"),
  })
  findOne(@Param("id") id: string) {
    return this.videoService.findOne(id);
  }

  @Patch(":id")
  @Authorize({
    namespace: VIDEO_NAMESPACE,
    relation: VIDEO_PERMISSIONS.edit,
    resourceId: fromParam("id"),
  })
  update(@Param("id") id: string, @Body() dto: UpdateVideoDto) {
    return this.videoService.update(id, dto);
  }

  @Delete(":id")
  @Authorize({
    namespace: VIDEO_NAMESPACE,
    relation: VIDEO_PERMISSIONS.delete,
    resourceId: fromParam("id"),
  })
  delete(@Param("id") id: string) {
    return this.videoService.delete(id);
  }
}

Each endpoint declares what it protects. The guard extracts the video ID and checks permissions.

Note: In production, make guards global and mark public endpoints with a decorator. See my previous post on secure-by-default patterns.

1.8 Managing Relations in Services

Authorization isn't just an HTTP concern. If you call services from background jobs or queues, guards don't run. In production, check permissions in services too.

For simplicity, this demo handles permission checks at the HTTP level (via guards) and only uses the AuthorizationService directly to create and delete relationships:

// video/video.service.ts
@Injectable()
export class VideoService {
  constructor(
    private readonly videoRepository: VideoRepository,
    private readonly authorizationService: AuthorizationService,
  ) {}

  async create(dto: CreateVideoDto, ownerId: string): Promise<VideoMetadata> {
    const video = await this.videoRepository.create({ ...dto, ownerId });

    await this.authorizationService.createRelation({
      namespace: VIDEO_NAMESPACE,
      object: video.id,
      relation: VIDEO_RELATIONS.owners,
      subjectId: ownerId,
    });

    return video;
  }

  async delete(id: string): Promise<void> {
    const video = await this.videoRepository.findOne(id);
    await this.videoRepository.delete(id);

    await this.authorizationService.deleteRelation({
      namespace: VIDEO_NAMESPACE,
      object: id,
      relation: VIDEO_RELATIONS.owners,
      subjectId: video.ownerId,
    });
  }
}

When a video is created, grant ownership. When deleted, revoke it. In systems with multiple entry points (HTTP, queues, jobs), service-level authorization is crucial.

1.9 Type Safety

We use TypeScript interfaces to make relation definitions type-safe:

// authorization/interfaces/relation-tuple.interface.ts
import { Request } from "express";

export interface RelationTupleDefinition {
  namespace: string;
  relation: string;
  resourceId: (request: Request) => string;
}

export interface ResolvedRelationTuple {
  namespace: string;
  object: string;
  relation: string;
  subjectId: string;
}

RelationTupleDefinition is for decorators. ResolvedRelationTuple is what the service uses. The guard converts between them.

Phase 2 – Adding Ory Keto

Now we connect a real authorization backend. The NestJS code stays mostly the same—we just wire up Keto and configure it.

2.1 Running Keto with Docker Compose

We'll run Keto with PostgreSQL using Docker Compose:

# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: keto
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret}
      POSTGRES_DB: keto
    volumes:
      - keto-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keto -d keto"]
      interval: 5s
      timeout: 5s
      retries: 5

  keto-migrate:
    image: oryd/keto:v0.12.0
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DSN: postgres://keto:${POSTGRES_PASSWORD:-secret}@postgres:5432/keto?sslmode=disable
    volumes:
      - ./keto:/config
    command: migrate up -y -c /config/keto.yml

  keto:
    image: oryd/keto:v0.12.0
    depends_on:
      keto-migrate:
        condition: service_completed_successfully
    ports:
      - "4466:4466"
      - "4467:4467"
    environment:
      DSN: postgres://keto:${POSTGRES_PASSWORD:-secret}@postgres:5432/keto?sslmode=disable
    volumes:
      - ./keto:/config
    command: serve -c /config/keto.yml

volumes:
  keto-postgres:

This runs three services:

  • postgres: stores relation tuples
  • keto-migrate: sets up the database schema
  • keto: serves two APIs:
    • Port 4466: read API for permission checks
    • Port 4467: write API for managing relationships

2.2 Modeling Permissions with OPL

Keto uses OPL (Ory Permission Language) to define permissions. This file is read by Keto, not compiled by TypeScript. IDE errors (missing imports, etc.) are normal—ignore them.

Keto doesn't answer "does user X have role Y?". It answers "is user X related to resource R in a way that grants permission P?".

You define:

  • what resources exist
  • what relations they have
  • how permissions flow through those relations

Example:

// keto/namespaces.keto.ts
// This file is read by Keto, not compiled by TypeScript.
// Keep in sync with authorization.constants.ts

class User implements Namespace {}

class Video implements Namespace {
  related: {
    owners: User[];
    editors: User[];
    viewers: User[];
  };

  permits = {
    view: (ctx: Context) => this.related.viewers.includes(ctx.subject) || this.related.editors.includes(ctx.subject) || this.related.owners.includes(ctx.subject),

    edit: (ctx: Context) => this.related.editors.includes(ctx.subject) || this.related.owners.includes(ctx.subject),

    delete: (ctx: Context) => this.related.owners.includes(ctx.subject),
  };
}

This defines three relations (owners, editors, viewers) and three permissions (view, edit, delete). Permissions are hierarchical: owners get everything, editors get view + edit, viewers only get view.

2.3 Keeping OPL and TypeScript in Sync

Namespace and relation names must match exactly between OPL and TypeScript. There's no type checking across this boundary. A typo silently breaks authorization.

You must keep them in sync manually:

// authorization/authorization.constants.ts
export const VIDEO_NAMESPACE = "Video"; // Must match OPL class name

export const VIDEO_RELATIONS = {
  owners: "owners", // Must match OPL relation name
  editors: "editors",
  viewers: "viewers",
} as const;

2.4 Connecting to Keto from NestJS

Install dependencies:

npm install @ory/keto-client @nestjs/config

Register ConfigModule globally and import it in AuthorizationModule:

// app.module.ts
@Module({
  imports: [ConfigModule.forRoot(), AuthorizationModule],
})
export class AppModule {}
// authorization/authorization.module.ts
@Module({
  imports: [ConfigModule],
  providers: [AuthorizationService, AuthorizationGuard],
  exports: [AuthorizationService, AuthorizationGuard],
})
export class AuthorizationModule {}

Environment variables (both default to localhost):

  • KETO_READ_URL
  • KETO_WRITE_URL

2.5 Implementing AuthorizationService

Now replace the fake implementation with real Keto calls:

// authorization/authorization.service.ts
@Injectable()
export class AuthorizationService {
  private readonly logger = new Logger(AuthorizationService.name);
  private readonly permissionApi: PermissionApi;
  private readonly relationshipApi: RelationshipApi;

  constructor(configService: ConfigService) {
    const ketoReadUrl = configService.get<string>("KETO_READ_URL", "http://localhost:4466");
    const ketoWriteUrl = configService.get<string>("KETO_WRITE_URL", "http://localhost:4467");

    this.permissionApi = new PermissionApi(new Configuration({ basePath: ketoReadUrl }));
    this.relationshipApi = new RelationshipApi(new Configuration({ basePath: ketoWriteUrl }));
  }

  async checkPermission(tuple: ResolvedRelationTuple): Promise<boolean> {
    try {
      const response = await this.permissionApi.checkPermission({
        namespace: tuple.namespace,
        object: tuple.object,
        relation: tuple.relation,
        subjectId: tuple.subjectId,
      });
      return response.data.allowed ?? false;
    } catch (error) {
      this.logger.error("Failed to check permission", error);
      return false;
    }
  }

  async createRelation(tuple: ResolvedRelationTuple): Promise<void> {
    try {
      await this.relationshipApi.createRelationship({
        createRelationshipBody: {
          namespace: tuple.namespace,
          object: tuple.object,
          relation: tuple.relation,
          subject_id: tuple.subjectId,
        },
      });
    } catch (error) {
      this.logger.error("Failed to create relation", error);
      throw error;
    }
  }

  async deleteRelation(tuple: ResolvedRelationTuple): Promise<void> {
    try {
      await this.relationshipApi.deleteRelationships({
        namespace: tuple.namespace,
        object: tuple.object,
        relation: tuple.relation,
        subjectId: tuple.subjectId,
      });
    } catch (error) {
      this.logger.error("Failed to delete relation", error);
      throw error;
    }
  }
}

The service uses two APIs: PermissionApi (read) and RelationshipApi (write). Guards and controllers stay unchanged.

2.6 Managing Permissions in Services

When a resource is created, grant access. When deleted, revoke it. The code is the same as shown in section 1.8—the AuthorizationService implementation changed, but the usage stayed the same.

This logic belongs in services, not controllers. Guards only protect HTTP endpoints. Service-level authorization protects your entire system.

2.7 Keto Configuration

Point Keto to your OPL definitions:

# keto/keto.yml
version: v0.12.0

namespaces:
  location: file:///config/namespaces.keto.ts

serve:
  read:
    host: 0.0.0.0
    port: 4466
  write:
    host: 0.0.0.0
    port: 4467

log:
  level: debug
  format: json

Done

You now have a working authorization setup with Ory Keto and NestJS. Permissions are modeled as relationships and enforced consistently at the API boundary.

By wrapping Keto in a clean abstraction layer, we kept the NestJS code testable and maintainable. You could swap Keto for another ReBAC system with minimal changes—only the AuthorizationService implementation would need to change.

The key: start with strong abstractions, then plug in the implementation.

A working example: https://github.com/rensjaspers/nestjs-keto-demo