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_URLKETO_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