DEV Community

Cover image for Building the Centralized Identity Hub: Secure API Handlers with Next.js & Prisma (Part 2)
kai gramm
kai gramm

Posted on

Building the Centralized Identity Hub: Secure API Handlers with Next.js & Prisma (Part 2)

Introduction
In Part 1, we discussed the architectural necessity of a Single Source of Truth (SSoT) and designed a robust PostgreSQL schema using Prisma. We established that managing users manually across fragmented services is a liability.

Today, we move from design to implementation. We will build the Identity Hub's engine using Next.js Route Handlers. Our goal is to create a secure, high-performance API that allows our "Spoke" applications (like Laravel services) to authenticate users and verify permissions without owning the data.

1. The Security Handshake: Service-to-Service Auth
Since our Identity Hub is a private internal service, we cannot leave the API endpoints open. We need a way to ensure that only authorized "Spoke" applications can talk to our Next.js Hub.

For this implementation, we will use a Secret Header-based Authentication (or an API Key).

import { headers } from 'next/headers';

export function validateServiceSecret() {
  const headerList = headers();
  const apiKey = headerList.get('x-identity-shared-secret');

  if (!apiKey || apiKey !== process.env.INTERNAL_SERVICE_SECRET) {
    throw new Error('Unauthorized: Service Secret Mismatch');
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Implementing the Authentication Logic
The most critical endpoint is the login. It must verify credentials and return a scoped payload. Note how we use Prisma’s include to fetch roles and permissions in a single query—maintaining the performance we promised in Part 1.

import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { validateServiceSecret } from '@/lib/auth-guard';

export async function POST(request: Request) {
  try {
    validateServiceSecret();
    const { email, password } = await request.json();

    const user = await prisma.user.findUnique({
      where: { email },
      include: {
        roles: {
          include: {
            role: {
              include: { permissions: { include: { permission: true } } }
            }
          }
        }
      }
    });

    if (!user || !user.isActive) {
      return NextResponse.json({ error: 'User not found or inactive' }, { status: 401 });
    }

    const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
    if (!isPasswordValid) {
      return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
    }

    // Flattening permissions for the Spoke application
    const permissions = Array.from(new Set(
      user.roles.flatMap(ur => ur.role.permissions.map(rp => rp.permission.slug))
    ));

    return NextResponse.json({
      user: {
        id: user.id,
        email: user.email,
        fullName: user.fullName,
        permissions: permissions
      }
    });

  } catch (error: any) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Why This Logic Matters for Scalability
In a traditional setup, you’d be tempted to return the whole User object. By flattening the permissions into a simple string array (e.g., ['users.create', 'billing.view']), we:

  1. Reduce Payload Size: Important for high-traffic service-to-service calls.
  2. Decouple Spoke Logic: The Laravel app doesn't need to know how the roles are mapped in the Hub; it only needs to know what the user is allowed to do.

  3. Handling Atomicity with Prisma Transactions
    When creating a user, you must ensure they are assigned a role immediately. If the user is created but the role assignment fails, your "Single Source of Truth" is now corrupted.

We use Prisma Transactions to ensure an "all-or-nothing" execution:

const newUser = await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: {
      email: data.email,
      passwordHash: hashedPw,
    }
  });

  await tx.userRole.create({
    data: {
      userId: user.id,
      roleId: defaultRoleId,
    }
  });

  return user;
});
Enter fullscreen mode Exit fullscreen mode

5. Performance: To Cache or Not to Cache?
In Part 1, we mentioned Redis. Since the Identity Hub will be hit every time a user logs in or performs a sensitive action in any Spoke app, database latency can become a bottleneck.

The Strategy:

  1. Cache Permissions: Store the flattened permission array in Redis with a TTL (Time to Live).
  2. Invalidation: Use a Webhook or a simple API call to clear the Redis cache whenever a user's role is updated in the Hub.

What’s Next?
We have the Hub, the Security Guard, and the API logic. But how does a legacy service actually consume this?

In Part 3: Connecting Laravel to the Hub, we will dive into the "Spoke" side. We will build a custom Guard in Laravel that bypasses its local database and validates everything against our Next.js Identity Hub.

Question for the community:
How do you handle session invalidation across multiple apps when a user is banned in the Central Hub? Let's discuss in the comments.

Top comments (0)