RBAC w Next.js: jak bezpiecznie wdrożyć role i uprawnienia?
Zbuduj RBAC w Next.js z użyciem Proxy, Server Actions i Data Access Layer. Zabezpiecz mutacje, zasoby organizacji, zmianę ról oraz testy uprawnień.
Maciej Sala
Founder StriveLab
11 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 1 sierpnia 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 systemu to jeden plik zawierający listę ról, mapę uprawnień i czyste funkcje sprawdzające. Uprawnienia nazywaj po akcjach wykonywanych na konkretnych zasobach (posts:edit, users:manage), a nie po rolach. Kod pyta wtedy „czy wolno edytować posty”, a nie „czy to admin”. Zmiana możliwości edytora wymaga edycji jednej tablicy zamiast szukania if (role === 'ADMIN') w całej aplikacji.
Code
// lib/rbac.tsexport const ROLES = { ADMIN: 'ADMIN', EDITOR: 'EDITOR', USER: 'USER',} as constexport type Role = (typeof ROLES)[keyof typeof ROLES]// Uprawnienia zgrupowane według domenyexport 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 PERMISSIONSexport function isRole(value: unknown): value is Role { return ( typeof value === 'string' && Object.values(ROLES).includes(value as Role) )}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. Dane zapisane w sesji nadal trzeba traktować jako wejście do kontroli, nie jako bezwarunkowy dowód dostępu.
Proxy w Next.js i ochrona ścieżek według roli
Proxy może mapować prefiks ścieżki na wymagane uprawnienie i wykonać redirect przed renderowaniem. To szybki punkt odcięcia, ale decyzja opiera się na sesji z żądania i pozostaje kontrolą optymistyczną.
Code
// proxy.tsimport { auth } from '@/auth'import { hasPermission, type Permission, type Role } from '@/lib/rbac'import { NextResponse } from 'next/server'// Mapowanie ścieżek na wymagane uprawnieniaconst routePermissions: Record<string, Permission> = { '/admin': 'users:manage', '/admin/users': 'users:manage', '/admin/settings': 'settings:edit', '/dashboard': 'posts:read', '/dashboard/posts/new': 'posts:create', '/dashboard/analytics': 'analytics:read',}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 const matchedRoute = protectedRoutes.find(([route]) => matchesPath(pathname, route), ) // Trasa spoza chronionych prefiksów. if (!matchedRoute) return NextResponse.next() // Chroniona trasa zawsze wymaga sesji. if (!session) { return NextResponse.redirect(new URL('/login', req.url)) } const [, requiredPermission] = matchedRoute const userRole = session.user.role as Role if (!hasPermission(userRole, requiredPermission)) { return NextResponse.redirect(new URL('/dashboard?error=forbidden', req.url)) } return NextResponse.next()})export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],}
Proxy stanowi wygodny filtr i mechanizm przekierowań, ale nie jest wiążącą granicą bezpieczeństwa. CVE-2025-29927 pozwalało ominąć Middleware spreparowanym nagłówkiem. W maju 2026 roku opisano kolejne obejścia przez warianty segment-prefetch i parametry tras dynamicznych. W lipcu 2026 roku ujawniono następny wariant dotyczący aplikacji Next.js 16 z App Routerem, Turbopackiem i określoną konfiguracją i18n.
Wniosek nie polega na dopisywaniu kolejnego wyjątku do Proxy. Autoryzacja musi ponownie zapaść w stronie lub funkcji pobierającej dane oraz wewnątrz każdej Server Action i Route Handlera. Aktualizacja frameworka nadal jest obowiązkowa. Dla lipcowej podatności Proxy poprawką była wersja 16.2.11, ale numer ten nie jest wieczną „bezpieczną wersją”. Włącz alerty zależności i przechodź na najnowsze poprawione wydanie swojej wspieranej 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.tsximport { 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.tsximport { 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 Componentimport { 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
Nawet jeśli UI ukrywa przycisk, użytkownik może wywołać bezpośrednio. Next.js traktuje eksportowaną i używaną akcję jak publiczny endpoint HTTP. Losowy identyfikator akcji utrudnia odgadywanie, ale nie zastępuje uwierzytelnienia, autoryzacji ani walidacji wejścia.
Osobne klasy błędów zachowują semantykę. Route Handler może mapować je na statusy HTTP przez instanceof, bez porównywania tekstu komunikatu. Wersja poniżej pobiera bieżący stan konta z bazy, dlatego odebranie roli albo blokada nie czekają na wygaśnięcie JWT:
Code
// lib/auth-helpers.tsimport 'server-only'import { auth } from '@/auth'import { db } from '@/lib/db'import { hasPermission, isRole, type Permission } 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?.user?.id) { throw new UnauthenticatedError() } const actor = await db.user.findUnique({ where: { id: session.user.id }, select: { id: true, role: true, status: true }, }) if (!actor || actor.status !== 'ACTIVE') { throw new UnauthenticatedError() } if (!isRole(actor.role) || !hasPermission(actor.role, permission)) { throw new ForbiddenError(permission) } return { userId: actor.id, role: actor.role }}
To dodatkowe zapytanie jest świadomym kosztem dla operacji chronionej. Możesz współdzielić wynik w obrębie jednego renderowania za pomocą React cache, ale nie zapisuj decyzji konkretnego użytkownika w publicznym cache między żądaniami. Jeśli używasz sesji bazodanowych, biblioteka może już odczytywać świeży rekord. Nadal sprawdź, czy zmiana roli, blokada konta i unieważnienie wszystkich sesji zaczynają działać w wymaganym czasie.
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')}
Co Next.js zabezpiecza automatycznie?
Server Actions porównują nagłówek Origin z hostem żądania, co daje wbudowaną ochronę przed częścią ataków CSRF. Gdy aplikacja działa za inną domeną proxy, dodatkowe hosty konfiguruj przez serverActions.allowedOrigins i utrzymuj tę listę możliwie krótką. Mechanizm same-origin nie mówi jednak, czy zalogowany użytkownik ma prawo usunąć wskazany rekord.
W lipcu 2026 roku advisory GHSA-955p-x3mx-jcvp pokazało, że identyfikatory wewnętrznych Server Functions mogły zostać ujawnione osobie niezalogowanej przez publiczne artefakty klienta. Poprawki znalazły się w Next.js 15.5.21 i 16.2.11. Zalecenie pozostaje niezmienne: uwierzytelniaj wewnątrz każdej granicy serwerowej, nawet jeśli akcja jest wywoływana tylko przez ukryty formularz.
Sprawdzenie typu dwóch pól nie wystarcza w aplikacji produkcyjnej. Zdefiniuj schemat wejścia, limity długości i dozwolone wartości, a błędy zwracaj w stałym formacie. Dodaj limit żądań dla kosztownych operacji i idempotency key tam, gdzie ponowienie mogłoby utworzyć drugi rekord albo drugi raz obciążyć klienta. Rate limiting nie zastępuje kontroli uprawnień.
Sprawdzenie requirePermission można pominąć, 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 odmówić wykonania operacji, do której użytkownik nie ma prawa. Miejsce wywołania nie zmienia wtedy wyniku, ponieważ autoryzacja jest częścią operacji.
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.tsimport 'server-only'import { 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'>function normalizePostId(postId: string) { if (typeof postId !== 'string' || !postId.trim()) { throw new InvalidInputError('Nieprawidłowy identyfikator posta') } return postId.trim()}function postScope(postId: string, actor: { userId: string; role: Role }) { return { id: normalizePostId(postId), ...(actor.role === 'ADMIN' ? {} : { authorId: actor.userId }), }}async function getPostForAction(postId: string, action: PostAction) { const actor = await requirePermission(action) const post = await db.post.findFirst({ where: postScope(postId, actor), }) if (!post) throw new ResourceNotFoundError('Post nie istnieje') return post}export const getPostForEdit = (postId: string) => getPostForAction(postId, 'posts:edit')export async function createPostRecord(input: { title: string content: string}) { const actor = 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: actor.userId, status: 'DRAFT', }, })}export async function publishPostRecord(postId: string) { const actor = await requirePermission('posts:publish') const result = await db.post.updateMany({ where: postScope(postId, actor), data: { status: 'PUBLISHED', publishedAt: new Date() }, }) if (result.count !== 1) { throw new ResourceNotFoundError('Post nie istnieje') }}export async function deletePostRecord(postId: string) { const actor = await requirePermission('posts:delete') const result = await db.post.deleteMany({ where: postScope(postId, actor), }) if (result.count !== 1) { throw new ResourceNotFoundError('Post nie istnieje') }}
postScope() łączy RBAC z regułą właścicielstwa. Warunek właściciela trafia do WHERE, więc baza nie wykonuje mutacji na cudzym rekordzie między wcześniejszym odczytem a zapisem. Ten błąd czasu sprawdzenia względem czasu użycia jest znany jako TOCTOU. Dla nieistniejącego i niedostępnego posta zwracamy ten sam błąd 404, aby nie ujawniać użytkownikowi, które identyfikatory istnieją.
Server Action i Route Handler wywołują funkcje z Data Access Layer, zamiast odtwarzać logikę uprawnień. Jedna polityka ogranicza liczbę pomyłek przy dodawaniu następnej ścieżki wejścia.
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ć.
RBAC w systemie wielu organizacji
Jedna rola w tabeli users wystarcza tylko wtedy, gdy obowiązuje w całej aplikacji. W systemie SaaS ten sam człowiek może być administratorem firmy A i obserwatorem firmy B. Rola powinna wtedy należeć do relacji członkostwa, na przykład Membership(userId, organizationId, role, status).
Identyfikator organizacji z adresu URL, formularza lub nagłówka jest danym od klienta. Możesz użyć go do wyszukania członkostwa, ale nie możesz mu zaufać przed tym sprawdzeniem:
Code
// data/organization-auth.tsimport 'server-only'import { auth } from '@/auth'import { db } from '@/lib/db'import { ForbiddenError, UnauthenticatedError } from '@/lib/auth-helpers'import { hasPermission, isRole, type Permission } from '@/lib/rbac'export async function requireOrganizationPermission( organizationId: string, permission: Permission,) { const session = await auth() if (!session?.user?.id) throw new UnauthenticatedError() const membership = await db.membership.findUnique({ where: { userId_organizationId: { userId: session.user.id, organizationId, }, }, select: { role: true, status: true }, }) if ( !membership || membership.status !== 'ACTIVE' || !isRole(membership.role) || !hasPermission(membership.role, permission) ) { throw new ForbiddenError(permission) } return { userId: session.user.id, organizationId }}
Samo sprawdzenie członkostwa nie kończy pracy. Zapytanie o zasób również musi zawierać organizationId:
Code
import { ResourceNotFoundError } from '@/lib/auth-helpers'export async function deleteOrganizationPost( organizationId: string, postId: string,) { await requireOrganizationPermission(organizationId, 'posts:delete') const result = await db.post.deleteMany({ where: { id: postId, organizationId }, }) if (result.count !== 1) { throw new ResourceNotFoundError('Post nie istnieje') }}
Organizację sprawdzaj razem z zasobem. Kontrola wyłącznie po roli pozwala administratorowi jednej organizacji podmienić postId i sięgnąć po rekord drugiej. Tę klasę błędów opisuje się jako IDOR lub BOLA. Najbezpieczniej zawęzić rekord po identyfikatorze zasobu i organizacji w jednym zapytaniu.
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.
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. instanceof mapuje typowany 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 wersji 15.1 oferuje za flagą eksperymentalną funkcje unauthorized() i forbidden() z next/navigation. Przerywają one render i wyświetlają dedykowany plik z poprawnym kodem statusu:
// app/admin/users/page.tsximport { 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 oraz roboty indeksujące dostają semantycznie poprawny status. Dopóki flaga jest eksperymentalna, traktuj to jako usprawnienie UX ponad wzorcami z tego artykułu, a nie jako zamiennik autoryzacji w Data Access Layer. Dokumentacja nadal nie zaleca tej funkcji w produkcji. unauthorized() nie może być wywołane w głównym layoucie.
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}
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ń
Dużym ryzykiem w RBAC jest cicha zmiana w mapie uprawnień. Ktoś przy okazji innego zadania dopisuje rolę do tablicy i USER nagle może usuwać posty. Ponieważ cała macierz jest czystym obiektem, podstawowy test pozostaje szybki:
Code
// lib/rbac.test.tsimport { 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 zmianę macierzy w widoczny diff, ale recenzent może zaakceptować go mechanicznie. Najwięcej luk wykrywają testy negatywne, które próbują przejść przez prawdziwe granice aplikacji:
Scenariusz
Oczekiwany wynik
Brak sesji wywołuje Server Action
odmowa przed mutacją
USER wywołuje endpoint administratora
403 bez zmiany danych
EDITOR podmienia identyfikator autora
404 i brak mutacji
Administrator firmy A podaje zasób firmy B
404 i brak wycieku danych
Rola w JWT to ADMIN, a w bazie USER
odmowa według świeżej roli
Konto zostało zablokowane
natychmiastowa odmowa
Nowa trasa pojawia się pod chronionym prefiksem
test potwierdza właściwe uprawnienie Proxy
Uruchamiaj te przypadki dla Server Actions, Route Handlers i funkcji DAL. Sam test komponentu, który nie pokazuje przycisku, nie bada bezpieczeństwa mutacji. Dodaj też test współbieżny dla operacji, w których właścicielstwo lub stan zasobu może zmienić się między żądaniami.
RBAC powinien odpowiadać po incydencie na pytania: kto zmienił rolę, czyjego konta dotyczyła zmiana, jaki zasób został zmodyfikowany i jaka polityka zezwoliła na operację. Dla działań administracyjnych zapisuj co najmniej:
identyfikator aktora i organizacji,
nazwę uprawnienia oraz typ operacji,
identyfikator zasobu, bez zapisywania całej poufnej zawartości,
wynik allowed albo denied i kod przyczyny,
czas, identyfikator żądania oraz źródło administracyjne,
poprzednią i nową rolę przy zmianie członkostwa.
Log audytowy wymaga ochrony przed zmianą. Ogranicz dostęp do zapisu i odczytu, ustal retencję oraz nie zapisuj tokenów, ciasteczek, haseł ani kompletnych danych formularzy. Alert o serii odmów może ujawnić próbę enumeracji zasobów, ale pojedyncza odmowa nadal może być zwykłym błędem użytkownika.
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
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. Podatności ujawnione w 2025 i 2026 roku pokazały kilka sposobów ominięcia kontroli wykonywanej wyłącznie w Middleware lub Proxy. Proxy odpowiada za szybki redirect i optymistyczny filtr, ale wiążąca decyzja musi zapaść w Server Action, Route Handlerze albo warstwie dostępu do danych.
Dlaczego po zmianie roli użytkownik wciąż ma stare uprawnienia?
Najczęściej dlatego, że rola znajduje się w JWT. W pokazanej konfiguracji Auth.js trafia do tokenu podczas logowania, a 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, 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.
Jak wdrożyć RBAC w aplikacji wielofirmowej?
Nie zapisuj jednej globalnej roli, jeśli użytkownik może być administratorem jednej organizacji i zwykłym członkiem drugiej. Rolę przechowuj w relacji członkostwa użytkownika z organizacją, a identyfikator organizacji oraz właścicielstwo zasobu sprawdzaj w tym samym zapytaniu, które wykonuje odczyt lub mutację.
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.
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
Founder StriveLab
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
Founder StriveLab
Server Actions to publiczne endpointy POST, które każdy może wywołać z poziomu narzędzi deweloperskich, narzędzia curl lub skryptu, dlatego formularza w interfejsie nie wolno traktować jak granicy bezpieczeństwa. Jeśli akcja nie waliduje danych wejściowych i nie sprawdza uprawnień, staje się podatna na ataki. Testy pilnują, aby niezweryfikowane dane nie trafiły do bazy, niezalogowany użytkownik nie wykonał niedozwolonej modyfikacji, a rewalidacja oraz inne efekty uboczne uruchomiły się dopiero po pomyślnym zakończeniu operacji.