Przejdź do treści

RBAC w Next.js: Jak zabezpieczyć role i uprawnienia?

Zapomnij o ochronie opartej wyłącznie na adresach URL. Zobacz, jak wdrożyć bezpieczny RBAC w Next.js z użyciem Proxy, Server Actions oraz Data Access Layer.

Maciej Sala

Founder StriveLab

9 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 14 lipca 2026)

Czym jest RBAC i po co w aplikacji Next.js?

W Next.js App Router trzeba egzekwować na kilku poziomach: (redirect i wstępny filtr ścieżek), Server Components (warunkowe renderowanie), Server Actions i Route Handlers (autoryzacja mutacji oraz endpointów) oraz Data Access Layer (kontrola dostępu przy samym zapytaniu). W starszych materiałach znajdziesz tu nazwę middleware, ale w Next.js 16 oficjalna konwencja to proxy.ts.

Każda warstwa pełni inną funkcję — i żadna pojedynczo nie wystarcza:

Diagram
Warstwy egzekwowania RBAC: żądanie przechodzi przez kolejne punkty kontroli, ale wiążąca decyzja zapada przy danych.

Definicja ról i uprawnień w RBAC

Fundament całego systemu to jeden plik, w którym mieszka wszystko, co dotyczy dostępu: lista ról, mapa uprawnień i funkcje sprawdzające. Kluczowa decyzja projektowa: uprawnienia nazywaj po akcjach na zasobach (posts:edit, users:manage), nie po rolach — kod pyta „czy wolno edytować posty”, a nie „czy to admin”. Dzięki temu zmiana tego, co wolno edytorowi, to edycja jednej tablicy, a nie polowanie na if (role === 'ADMIN') rozsiane po aplikacji.

Code
// lib/rbac.ts
export const ROLES = {
  ADMIN: 'ADMIN',
  EDITOR: 'EDITOR',
  USER: 'USER',
} as const
 
export type Role = (typeof ROLES)[keyof typeof ROLES]
 
// Uprawnienia zgrupowane według domeny
export const PERMISSIONS = {
  // Posty
  'posts:read': [ROLES.ADMIN, ROLES.EDITOR, ROLES.USER],
  'posts:create': [ROLES.ADMIN, ROLES.EDITOR],
  'posts:edit': [ROLES.ADMIN, ROLES.EDITOR],
  'posts:delete': [ROLES.ADMIN, ROLES.EDITOR],
  'posts:publish': [ROLES.ADMIN, ROLES.EDITOR],
 
  // Użytkownicy
  'users:read': [ROLES.ADMIN],
  'users:manage': [ROLES.ADMIN],
 
  // Ustawienia
  'settings:read': [ROLES.ADMIN, ROLES.EDITOR],
  'settings:edit': [ROLES.ADMIN],
 
  // Analityka
  'analytics:read': [ROLES.ADMIN, ROLES.EDITOR],
} as const satisfies Record<string, readonly Role[]>
 
export type Permission = keyof typeof PERMISSIONS
 
export function hasPermission(role: Role, permission: Permission): boolean {
  const allowedRoles: readonly Role[] = PERMISSIONS[permission]
  return allowedRoles.includes(role)
}
 
export function hasAllPermissions(
  role: Role,
  permissions: readonly Permission[],
): boolean {
  return permissions.every((p) => hasPermission(role, p))
}
 
export function hasAnyPermission(
  role: Role,
  permissions: readonly Permission[],
): boolean {
  return permissions.some((p) => hasPermission(role, p))
}

Skąd sesja zna rolę? Callbacki Auth.js

Cały artykuł opiera się na session.user.role i zanim ruszymy dalej, warto zobaczyć, skąd ta wartość się w ogóle bierze. Rolę przechowujesz w bazie (kolumna role w tabeli users albo tabela relacji przy wielu rolach) i przy strategii przepinasz ją do sesji przez dwa callbacki:

Code
// auth.ts (Auth.js v5)
import NextAuth from 'next-auth'
import type { Role } from '@/lib/rbac'
 
export const { auth, handlers, signIn, signOut } = NextAuth({
  // ...adapter i providerzy
  callbacks: {
    jwt({ token, user }) {
      // `user` jest dostępny tylko przy logowaniu — wtedy zapisujemy ID i rolę
      if (user) {
        token.id = user.id
        token.role = user.role
      }
      return token
    },
    session({ session, token }) {
      session.user.id = token.id as string
      session.user.role = token.role as Role
      return session
    },
  },
})

Typy session.user.id i session.user.role deklarujesz raz przez module augmentation (declare module 'next-auth' oraz declare module 'next-auth/jwt'), żeby nie rzutować ich w każdym pliku — w przykładach niżej zostawiam jawne rzutowanie roli, żeby było widać, skąd wartość pochodzi.

Proxy w Next.js i ochrona ścieżek według roli

Pierwsza linia obrony: mapa „ścieżka → wymagane uprawnienie” i redirect, zanim w ogóle zacznie się render. To najtańszy punkt odcięcia — ale, jak zobaczysz za chwilę, także najmniej godny zaufania.

Code
// proxy.ts
import { auth } from '@/auth'
import { hasPermission, type Permission, type Role } from '@/lib/rbac'
import { NextResponse } from 'next/server'
 
// Mapowanie ścieżek na wymagane uprawnienia
const routePermissions: Record<string, Permission> = {
  '/admin': 'users:manage',
  '/admin/users': 'users:manage',
  '/admin/settings': 'settings:edit',
  '/dashboard/posts/new': 'posts:create',
  '/dashboard/analytics': 'analytics:read',
}
 
const publicPaths = ['/', '/login', '/register', '/blog']
const protectedRoutes = Object.entries(routePermissions).sort(
  ([left], [right]) => right.length - left.length,
)
 
function matchesPath(pathname: string, route: string) {
  return pathname === route || pathname.startsWith(`${route}/`)
}
 
export const proxy = auth((req) => {
  const { pathname } = req.nextUrl
  const session = req.auth
 
  // Ścieżki publiczne — bez autoryzacji
  const isPublic = publicPaths.some((path) => matchesPath(pathname, path))
  if (isPublic) {
    return
  }
 
  // Wymagane logowanie
  if (!session) {
    return NextResponse.redirect(new URL('/login', req.url))
  }
 
  // Sprawdź uprawnienia per ścieżka
  const matchedRoute = protectedRoutes.find(([route]) =>
    matchesPath(pathname, route),
  )
 
  if (matchedRoute) {
    const [, requiredPermission] = matchedRoute
    const userRole = session.user.role as Role
 
    if (!hasPermission(userRole, requiredPermission)) {
      return NextResponse.redirect(
        new URL('/dashboard?error=forbidden', req.url),
      )
    }
  }
})
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

Proxy stanowi wygodny filtr i mechanizm przekierowań, ale nie jest ostateczną granicą bezpieczeństwa. Dowiodło tego CVE-2025-29927, czyli krytyczna podatność, w której spreparowany nagłówek x-middleware-subrequest pozwalał całkowicie ominąć logikę Middleware i uzyskać dostęp do chronionych tras. Po tym incydencie oficjalna rekomendacja ze strony Next.js brzmi, że Proxy ma uzupełniać, a nie zastępować autoryzację umieszczoną bliżej danych. Właśnie dlatego sekcje dotyczące Server Actions i Data Access Layer, które omówimy poniżej, pokazują właściwe, wiążące warstwy egzekwowania uprawnień. Należy również pamiętać o aktualizowaniu Next.js (podatność załatano w wersjach 12.3.5, 13.5.9, 14.2.25 i 15.2.3 poszczególnych gałęzi).

Server Components i warunkowe renderowanie po uprawnieniach

Druga warstwa działa na poziomie UI, czyli nawigacja i przyciski, których rola nie obejmuje, w ogóle nie trafiają do HTML-a. To coś więcej niż display: none — użytkownik nie dostaje nawet informacji, że dana sekcja istnieje. Typowe miejsce to layout dashboardu:

Code
// app/dashboard/layout.tsx
import { auth } from '@/auth'
import { hasPermission, type Role } from '@/lib/rbac'
import { redirect } from 'next/navigation'
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()
  if (!session) redirect('/login')
 
  const role = session.user.role as Role
 
  return (
    <div className="flex">
      <nav className="w-64 space-y-1 border-r p-4">
        <NavLink href="/dashboard/">Dashboard</NavLink>
        <NavLink href="/dashboard/posts/">Posty</NavLink>
 
        {hasPermission(role, 'posts:create') && (
          <NavLink href="/dashboard/posts/new/">Nowy post</NavLink>
        )}
 
        {hasPermission(role, 'analytics:read') && (
          <NavLink href="/dashboard/analytics/">Analityka</NavLink>
        )}
 
        {hasPermission(role, 'users:manage') && (
          <NavLink href="/admin/users/">Użytkownicy</NavLink>
        )}
 
        {hasPermission(role, 'settings:edit') && (
          <NavLink href="/admin/settings/">Ustawienia</NavLink>
        )}
      </nav>
      <main className="flex-1 p-8">{children}</main>
    </div>
  )
}

Uwaga na konsekwencję dla renderowania, bo auth() czyta ciasteczka i trasa z takim layoutem korzysta z danych konkretnego żądania. To naturalne dla dashboardu za logowaniem, ale nie mieszaj warunkowego UI dla danej roli ze stronami, które chcesz mieć statyczne albo buforowane wspólnie między użytkownikami. Layout może zostać zachowany podczas nawigacji po stronie klienta, dlatego sprawdzenie w nim służy UI i nie zastępuje autoryzacji przy danych.

Komponent PermissionGate dla RBAC

Warunek hasPermission(...) && (...) powtórzony piąty raz w JSX zaczyna szumieć. Mały komponent serwerowy sprowadza go do deklaratywnej bramki:

Code
// components/permission-gate.tsx
import { auth } from '@/auth'
import { hasPermission, type Permission, type Role } from '@/lib/rbac'
 
interface PermissionGateProps {
  permission: Permission
  children: React.ReactNode
  fallback?: React.ReactNode
}
 
export async function PermissionGate({
  permission,
  children,
  fallback = null,
}: PermissionGateProps) {
  const session = await auth()
 
  if (!session) return fallback
 
  const role = session.user.role as Role
 
  if (!hasPermission(role, permission)) return fallback
 
  return <>{children}</>
}
Code
// Użycie w Server Component
import { PermissionGate } from '@/components/permission-gate'
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
 
      <PermissionGate permission="posts:edit">
        <a href={`/dashboard/posts/${post.id}/edit`}>Edytuj</a>
      </PermissionGate>
 
      <PermissionGate permission="posts:delete">
        <DeletePostButton postId={post.id} />
      </PermissionGate>
    </article>
  )
}

Server Actions i autoryzacja mutacji

Najważniejszy poziom ochrony. Nawet jeśli UI ukrywa przycisk — sprytny użytkownik może wywołać bezpośrednio. Dlatego autoryzacja w Server Actions jest obowiązkowa, bezpośrednio w akcji albo w wywoływanej przez nią funkcji Data Access Layer.

Pomocnik requirePermission rzuca typowane błędy zamiast gołych Error z komunikatem — dzięki temu Route Handler niżej mapuje je na statusy HTTP przez instanceof, a nie przez porównywanie treści komunikatu:

Code
// lib/auth-helpers.ts
import { auth } from '@/auth'
import { hasPermission, type Permission, type Role } from '@/lib/rbac'
 
export class UnauthenticatedError extends Error {
  constructor() {
    super('Musisz być zalogowany')
  }
}
 
export class ForbiddenError extends Error {
  constructor(permission: Permission) {
    super(`Brak uprawnienia: ${permission}`)
  }
}
 
export class InvalidInputError extends Error {}
 
export class ResourceNotFoundError extends Error {}
 
export async function requirePermission(permission: Permission) {
  const session = await auth()
 
  if (!session) {
    throw new UnauthenticatedError()
  }
 
  const role = session.user.role as Role
 
  if (!hasPermission(role, permission)) {
    throw new ForbiddenError(permission)
  }
 
  return session
}
Code
// actions/post-actions.ts
'use server'
 
import {
  createPostRecord,
  deletePostRecord,
  publishPostRecord,
} from '@/data/posts'
import { revalidatePath } from 'next/cache'
 
export async function createPost(formData: FormData) {
  const titleValue = formData.get('title')
  const contentValue = formData.get('content')
 
  if (typeof titleValue !== 'string' || typeof contentValue !== 'string') {
    throw new Error('Nieprawidłowy format danych')
  }
 
  const title = titleValue.trim()
  const content = contentValue.trim()
 
  if (!title || !content) {
    throw new Error('Tytuł i treść są wymagane')
  }
 
  await createPostRecord({
    title,
    content,
  })
 
  revalidatePath('/dashboard/posts')
  return { success: true }
}
 
export async function publishPost(postId: string) {
  await publishPostRecord(postId)
 
  revalidatePath('/dashboard/posts')
  revalidatePath(`/blog`)
}
 
export async function deletePost(postId: string) {
  await deletePostRecord(postId)
  revalidatePath('/dashboard/posts')
}

Data Access Layer i autoryzacja przy danych

Sprawdzenie requirePermission można pominąć — np. gdy inny fragment kodu zaimportuje db bezpośrednio i zapyta o post bez przejścia przez akcję. Dlatego oficjalna rekomendacja Next.js idzie o krok dalej: sama funkcja dostępu do danych powinna znać rolę i sama odmówić wykonania operacji, do której użytkownik nie ma prawa. Wtedy nie ma znaczenia, z którego miejsca kodu ktoś ją wywoła — autoryzacja jest częścią operacji, nie osobnym krokiem przed nią.

W praktyce taki moduł oznacz jako server-only, waliduj argumenty, zanim dotkniesz bazy, i nie zwracaj do klienta surowych rekordów, jeśli zawierają pola wewnętrzne. Ogranicz też bezpośredni import klienta db do modułów Data Access Layer, bo samo server-only nie wymusza przejścia przez reguły autoryzacji. Data Access Layer ma być miejscem, w którym spotykają się trzy decyzje: kto pyta, do czego ma prawo i jaki minimalny kształt danych wolno zwrócić dalej.

Code
// data/posts.ts
import 'server-only'
 
import {
  ForbiddenError,
  InvalidInputError,
  ResourceNotFoundError,
  requirePermission,
} from '@/lib/auth-helpers'
import { type Permission, type Role } from '@/lib/rbac'
import { db } from '@/lib/db'
 
type PostAction = Extract<
  Permission,
  'posts:edit' | 'posts:publish' | 'posts:delete'
>
 
// Jedna reguła dla wszystkich mutacji posta:
// RBAC (czy rola ma uprawnienie) + właścicielstwo (ADMIN dowolny post,
// pozostałe role tylko własne)
async function getPostForAction(postId: string, action: PostAction) {
  if (typeof postId !== 'string' || !postId.trim()) {
    throw new InvalidInputError('Nieprawidłowy identyfikator posta')
  }
 
  const normalizedPostId = postId.trim()
 
  const session = await requirePermission(action)
  const role = session.user.role as Role
 
  const post = await db.post.findUnique({ where: { id: normalizedPostId } })
  if (!post) throw new ResourceNotFoundError('Post nie istnieje')
 
  const isOwner = post.authorId === session.user.id
  if (role !== 'ADMIN' && !isOwner) {
    throw new ForbiddenError(action)
  }
 
  return post
}
 
export const getPostForEdit = (postId: string) =>
  getPostForAction(postId, 'posts:edit')
 
export const getPostForPublish = (postId: string) =>
  getPostForAction(postId, 'posts:publish')
 
export const getPostForDelete = (postId: string) =>
  getPostForAction(postId, 'posts:delete')
 
export async function createPostRecord(input: {
  title: string
  content: string
}) {
  const session = await requirePermission('posts:create')
  const title = input.title.trim()
  const content = input.content.trim()
 
  if (!title || !content) {
    throw new InvalidInputError('Tytuł i treść są wymagane')
  }
 
  return db.post.create({
    data: {
      title,
      content,
      authorId: session.user.id,
      status: 'DRAFT',
    },
  })
}
 
export async function publishPostRecord(postId: string) {
  const post = await getPostForPublish(postId)
 
  await db.post.update({
    where: { id: post.id },
    data: { status: 'PUBLISHED', publishedAt: new Date() },
  })
}
 
export async function deletePostRecord(postId: string) {
  const post = await getPostForDelete(postId)
 
  await db.post.delete({ where: { id: post.id } })
}

Zwróć uwagę, co spotyka się w getPostForAction: czysty RBAC (requirePermission) i reguła właścicielstwa (isOwner), czyli ten hybrydowy model „RBAC z domieszką ABAC” z FAQ — wyrażony raz, dla wszystkich trzech operacji. Server Action i Route Handler wywołują funkcje z Data Access Layer, zamiast osobno odtwarzać tę samą logikę uprawnień — jedno miejsce, jedna prawda, mniej okazji, żeby ktoś zapomniał o sprawdzeniu roli w nowym kodzie.

W bardziej rozbudowanej aplikacji możesz rozdzielić funkcje getPostForEdit() i getPostDTO(): pierwsza zwraca pełny rekord tylko kodowi serwerowemu, druga buduje bezpieczny obiekt dla UI, bez pól technicznych, tokenów, notatek moderacyjnych czy danych autora, których użytkownik nie powinien widzieć.

Route Handler i autoryzacja dostępu do API

Server Action chroni mutacje wywołane z formularzy i komponentów w tej samej aplikacji, ale route.ts w app/api to osobny, publiczny endpoint HTTP — mogą go wywołać webhooki, aplikacja mobilna albo fetch z zewnątrz. W pokazanej konfiguracji Proxy pomija /api, więc autoryzacja dla konkretnego uprawnienia musi być w handlerze albo w wywoływanej przez niego funkcji Data Access Layer.

Code
// app/api/posts/[id]/route.ts
import {
  ForbiddenError,
  InvalidInputError,
  ResourceNotFoundError,
  UnauthenticatedError,
} from '@/lib/auth-helpers'
import { deletePostRecord } from '@/data/posts'
 
export async function DELETE(
  _request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  try {
    const { id } = await params
    await deletePostRecord(id)
 
    return Response.json({ success: true })
  } catch (error) {
    if (error instanceof UnauthenticatedError) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }
    if (error instanceof ForbiddenError) {
      return Response.json({ error: 'Forbidden' }, { status: 403 })
    }
    if (error instanceof InvalidInputError) {
      return Response.json({ error: 'Invalid input' }, { status: 400 })
    }
    if (error instanceof ResourceNotFoundError) {
      return Response.json({ error: 'Not found' }, { status: 404 })
    }
 
    return Response.json({ error: 'Internal error' }, { status: 500 })
  }
}

Wzorzec jest identyczny jak w Server Action: Route Handler nie dotyka bazy bezpośrednio, tylko wywołuje funkcję z Data Access Layer. Dzięki temu endpoint HTTP, formularz i komponent serwerowy korzystają z tej samej reguły autoryzacji. Typowane klasy błędów procentują właśnie tutaj — instanceof mapuje wyjątek na 400, 401, 403 lub 404, a nieprzewidziane błędy dostają 500 bez wyciekania szczegółów do odpowiedzi.

unauthorized() i forbidden() — semantyczne odpowiedzi zamiast redirectu

Redirect na /dashboard?error=forbidden z Proxy działa, ale ma dwie wady: użytkownik ląduje pod innym URL-em niż ten, o który prosił, a odpowiedź ma status 307, nie 403. Next.js (od 15.1, za flagą eksperymentalną) oferuje semantycznie trafniejszą alternatywę — funkcje unauthorized() i forbidden() z next/navigation, które przerywają render i wyświetlają dedykowany plik z poprawnym kodem statusu:

Code
// next.config.ts
const nextConfig = {
  experimental: {
    authInterrupts: true,
  },
}
Code
// app/admin/users/page.tsx
import { auth } from '@/auth'
import { hasPermission, type Role } from '@/lib/rbac'
import { forbidden, unauthorized } from 'next/navigation'
 
export default async function AdminUsersPage() {
  const session = await auth()
  if (!session) unauthorized() // renderuje app/unauthorized.tsx z 401
 
  if (!hasPermission(session.user.role as Role, 'users:manage')) {
    forbidden() // renderuje app/forbidden.tsx z 403
  }
 
  return <UsersTable />
}

Obok tego tworzysz app/unauthorized.tsx (np. z formularzem logowania) i app/forbidden.tsx (komunikat o braku dostępu). Użytkownik zostaje pod oryginalnym URL-em i widzi jasny ekran, a klienci HTTP i roboty indeksujące dostają semantycznie poprawny status. Dopóki flaga jest eksperymentalna, traktuj to jako usprawnienie UX ponad wzorcami z tego artykułu — nie jako zamiennik autoryzacji w Data Access Layer ani rozwiązanie zalecane dziś do produkcji.

Client Component i hook usePermission

Ostatnia warstwa to komponenty interaktywne, które nie mają dostępu do auth() — rolę czytają z sesji po stronie klienta przez useSession. Hook wymaga, aby tę część aplikacji obejmował SessionProvider. Ta sama mapa PERMISSIONS działa po obu stronach, bo to czysty obiekt TypeScript bez zależności serwerowych:

Code
// hooks/use-permission.ts
'use client'
 
import { useSession } from 'next-auth/react'
import { hasPermission, type Permission, type Role } from '@/lib/rbac'
 
export function usePermission(permission: Permission): boolean {
  const { data: session } = useSession()
 
  if (!session?.user?.role) return false
 
  return hasPermission(session.user.role as Role, permission)
}
 
export function useRole(): Role | null {
  const { data: session } = useSession()
  return (session?.user?.role as Role) || null
}
Code
'use client'
 
import { usePermission } from '@/hooks/use-permission'
 
export function PostActions({ postId }: { postId: string }) {
  const canEdit = usePermission('posts:edit')
  const canDelete = usePermission('posts:delete')
 
  return (
    <div className="flex gap-2">
      {canEdit && <button className="text-blue-600">Edytuj</button>}
      {canDelete && <button className="text-red-600">Usuń</button>}
    </div>
  )
}

Pamiętaj, że ukrywanie przycisku na UI to ruch w stronę wygody, ale nie bezpieczeństwa. Prawdziwa ochrona jest w Server Actions, Route Handlerach i Data Access Layer. Proxy zostaje filtrem oraz miejscem na redirect, ale nie powinno być jedyną granicą dostępu.

Jeśli uprawnienie zależy od właścicielstwa zasobu, sam hook usePermission('posts:delete') nie wystarczy do idealnego UX, bo mówi tylko o roli, nie o konkretnym poście. Wtedy lepiej zwrócić z serwera DTO z polami canEdit i canDelete, wyliczonymi przez tę samą logikę, która działa w Data Access Layer.

Testowanie macierzy uprawnień

Największe ryzyko w RBAC to nie luka w kodzie, tylko cicha zmiana w mapie uprawnień — ktoś przy okazji innego zadania dopisze rolę do tablicy i USER nagle może usuwać posty. Ponieważ cała macierz to czysty obiekt, test jest trywialny i błyskawiczny:

Code
// lib/rbac.test.ts
import { describe, expect, it } from 'vitest'
import { hasPermission, PERMISSIONS, ROLES } from './rbac'
 
it('macierz uprawnień nie zmienia się przypadkiem', () => {
  // Snapshot wymusza świadome zatwierdzenie każdej zmiany dostępu
  expect(PERMISSIONS).toMatchSnapshot()
})
 
describe('granice ról', () => {
  it('USER nie ma żadnego uprawnienia do mutacji', () => {
    expect(hasPermission(ROLES.USER, 'posts:create')).toBe(false)
    expect(hasPermission(ROLES.USER, 'posts:edit')).toBe(false)
    expect(hasPermission(ROLES.USER, 'posts:delete')).toBe(false)
    expect(hasPermission(ROLES.USER, 'posts:publish')).toBe(false)
    expect(hasPermission(ROLES.USER, 'users:manage')).toBe(false)
    expect(hasPermission(ROLES.USER, 'settings:edit')).toBe(false)
  })
 
  it('EDITOR nie zarządza użytkownikami ani ustawieniami', () => {
    expect(hasPermission(ROLES.EDITOR, 'users:manage')).toBe(false)
    expect(hasPermission(ROLES.EDITOR, 'settings:edit')).toBe(false)
  })
})

Snapshot PERMISSIONS zamienia każdą zmianę macierzy w jawną decyzję w code review, a testy granic dokumentują, czego danej roli nie wolno — to znacznie cenniejsze niż testowanie ścieżek pozytywnych.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

RBAC vs ABAC — co wybrać?

RBAC (role-based) wystarcza dla większości aplikacji, ponieważ jest wyraźnie prostszy w implementacji i zrozumieniu. ABAC (attribute-based) jest potrzebny, gdy uprawnienia zależą od atrybutów zasobu, np. „autor może edytować tylko swoje posty w ciągu 24 h od publikacji”. W praktyce wiele systemów to RBAC z kilkoma regułami ABAC dla najbardziej szczegółowych przypadków.

Jak dodać nową rolę?

Dodaj ją do obiektu ROLES, przypisz uprawnienia w PERMISSIONS, a TypeScript od razu sprawdzi poprawność jej użycia w typowanym kodzie. To główna zaleta trzymania definicji w jednym pliku typowanym, bo kompilator pilnuje spójności nazw ról i uprawnień.

Gdzie przechowywać role użytkowników?

W bazie danych, najczęściej w tabeli users jako kolumna role albo w osobnej tabeli relacji, jeśli użytkownik może mieć kilka ról. W Auth.js możesz udostępnić rolę w session.user.role przez callbacki jwt i session przy strategii JWT albo przez sesję bazodanową przy strategii database. Nie trzymaj roli wyłącznie po stronie klienta, ponieważ to wartość, której serwer musi być pewien przy każdym żądaniu.

Czy wystarczy chronić trasy w Proxy / middleware?

Nie. Po CVE-2025-29927 (obejście Middleware nagłówkiem x-middleware-subrequest) oficjalna rekomendacja brzmi: middleware ma uzupełniać, a nie zastępować autoryzację bliżej danych. Proxy odpowiada za wygodny redirect i wstępny filtr, ale wiążąca decyzja musi zapaść w Server Action albo w warstwie dostępu do danych.

Dlaczego po zmianie roli użytkownik wciąż ma stare uprawnienia?

Najczęściej dlatego, że rola siedzi w JWT. Przy strategii jwt w Auth.js rola jest zapisywana do tokenu w momencie logowania i callback jwt nie odpytuje bazy przy każdym żądaniu. Degradacja z ADMIN do USER zacznie obowiązywać dopiero po wygaśnięciu tokenu albo ponownym logowaniu. Jeśli zmiany ról muszą działać natychmiast (np. blokada konta), użyj strategii database, skróć czas życia tokenu albo weryfikuj rolę świeżym zapytaniem w Data Access Layer przy operacjach wrażliwych.

Czym są unauthorized() i forbidden() w Next.js?

To eksperymentalne funkcje z next/navigation (za flagą experimental.authInterrupts), które przerywają wykonanie i wyświetlają plik unauthorized.tsx ze statusem 401 albo forbidden.tsx ze statusem 403. Dają semantycznie poprawną odpowiedź zamiast redirectu z parametrem ?error=forbidden — użytkownik widzi jasny ekran „brak dostępu” pod oryginalnym URL-em, a klienci HTTP dostają właściwy kod statusu. Dokumentacja Next.js nadal nie zaleca ich używania na produkcji.

Po co autoryzować w Server Actions, skoro UI ukrywa przyciski?

Bo Server Action jest wywoływana żądaniem HTTP i można ją uruchomić bezpośrednio, z pominięciem interfejsu. Ukrycie przycisku to wygoda dla użytkownika; jedyną realną ochroną mutacji jest sprawdzenie uprawnień na serwerze, w każdej akcji, przed dotknięciem bazy.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Route Handlers czy Server Actions? Kiedy co wybrać

App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.

Maciej Sala

Maciej Sala

Founder StriveLab

Middleware w Next.js — 7 zastosowań i typowych pułapek

Większość deweloperów może kojarzyć middleware w Next.js ze sprawdzaniem, czy użytkownik jest zalogowany, podczas gdy dla żądań objętych matcherem może ono podjąć decyzję jeszcze przed routingiem: przypisać wariant eksperymentu, zastosować limit, wykonać rewrite albo dodać zależny od requestu nagłówek. W artykule chciałem omówić siedem optymalnych i bezpiecznych zastosowań Proxy.

Maciej Sala

Maciej Sala

Founder StriveLab

Repository i Service Layer w Next.js dla Server Actions

W Next.js traktuj Server Actions i Route Handlers jako cienkie adaptery wejścia: pobierają sesję, walidują payload i mapują odpowiedź. Reguły biznesowe trzymaj w Service Layer, zapytania do bazy w Repository, a transakcje spinaj przez Unit of Work. Taki podział nie jest potrzebny w każdym CRUD-zie, ale zaczyna się zwracać, gdy jedna operacja dotyka kilku tabel, kilku wejść albo kilku reguł.

Maciej Sala

Maciej Sala

Founder StriveLab