Zbuduj bezpieczne API użytkowników od zera.
Praktyczny kurs od pustego folderu: NestJS 11, PostgreSQL w Dockerze, Prisma 7, bcrypt oraz access i refresh tokeny z rotacją.
Efekt końcowy
API rejestruje i loguje użytkownika, chroni endpointy przez Bearer JWT, bezpiecznie odświeża sesję i umie wylogować jedno albo wszystkie urządzenia.
- PostgreSQL + Prisma 7
- JWT access token: 15 minut
- Refresh token: hash w bazie + rotacja
Najpierw: jak myśleć o backendzie?
Backend to program działający na serwerze. Przyjmuje prośby od aplikacji (frontend, aplikacja mobilna albo inny serwer), sprawdza je, wykonuje reguły aplikacji, rozmawia z bazą danych i odsyła odpowiedź — najczęściej JSON.
Użytkownik klika „Zaloguj” w aplikacji
↓
Frontend wysyła POST /auth/login z e-mailem i hasłem
↓
Controller odbiera HTTP request i przekazuje dane dalej
↓
Service sprawdza hasło i wykonuje logikę logowania
↓
Prisma odczytuje użytkownika z PostgreSQL
↓
Backend zwraca JSON z użytkownikiem i tokenami
Frontend, backend, API i baza danych
Frontend to ekran i interakcje użytkownika.
Backend jest strażnikiem danych i reguł.
API to umowa komunikacji między nimi, np.
POST /auth/login.
Baza danych przechowuje trwałe informacje:
użytkowników, tokeny, zamówienia czy wpisy.
HTTP w praktyce
GET /users/123.
body i
headers. Response ma status oraz dane. Najczęstsze
statusy: 200 OK, 201 Created,
400 Bad Request, 401 Unauthorized,
409 Conflict i
500 Internal Server Error.
Controller, service i moduł — dlaczego to rozdzielamy?
NestJS używa architektury podobnej do MVC, ale backend API zwykle nie renderuje widoków HTML. Najważniejsze są Controller i Service: pierwszy obsługuje HTTP, drugi zawiera reguły biznesowe.
To nie jest podział „dla porządku”
Controller: „Dostałem POST /auth/login. Oto dane z body.”
Service: „Znajdę użytkownika, sprawdzę hasło, wydam tokeny.”
Prisma: „Wykonam zapytanie do PostgreSQL.”
Database: „Zwracam zapisany rekord użytkownika.”
Dzięki temu controller nie miesza HTTP, haseł, tokenów i SQL w jednej metodzie. Tę samą logikę z serwisu możesz później wywołać z endpointu, zadania cyklicznego albo testu — bez kopiowania kodu.
Mapa komponentów NestJS
AuthModule.
@Body(),
@Param(), query i nagłówki; powinien być
cienki.
PrismaService do
UsersService.
constructor(private readonly prisma: PrismaService),
czytaj to jako: „ten serwis potrzebuje narzędzia do rozmowy z
bazą; Nest dostarczy jedną gotową instancję”. Nie tworzysz ręcznie
new PrismaService() w każdym pliku.
Jak dodawać endpoint i nową funkcję?
Nie zaczynaj od pisania controllera. Zacznij od zdania opisującego potrzebę użytkownika, a potem przejdź przez stały schemat decyzji.
1. Potrzeba: „Zalogowany użytkownik ma zobaczyć swój profil”.
2. Dane: potrzebuję User { id, email, name, bio }.
3. Umowa API: GET /users/:id albo GET /auth/me?
4. Uprawnienia: kto może to zobaczyć? Czy endpoint wymaga JWT?
5. DTO: jakie dane przyjmuję i jak je waliduję?
6. Service: jakie reguły wykonuję i jakie błędy zwracam?
7. Baza: czy model już zawiera wymagane dane i indeksy?
8. Testy: sukces, brak danych, brak uprawnień, błędne dane.
Przykład: endpoint pobierający użytkownika
// users.controller.ts — warstwa HTTP
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findById(id);
}
// users.service.ts — logika i baza
async findById(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException('User not found');
return this.toPublicUser(user);
}
Gdy endpoint ma zmieniać dane, np. PATCH /users/me,
dodajesz osobne DTO z polami, które wolno aktualizować. Nie
przyjmuj całego obiektu użytkownika „w ciemno”, bo klient mógłby
spróbować przesłać passwordHash, rolę lub cudze ID.
- Umiem opisać potrzebę użytkownika jednym zdaniem.
- Wiem, jaka metoda HTTP i URL pasują do tej potrzeby.
- Rozdzielam HTTP w controllerze od reguł w serwisie.
Jak myśleć o bazie danych i modelach Prisma?
Model Prisma zwykle odpowiada tabeli w PostgreSQL. Jeden
obiekt
User to rekord (wiersz), a email lub
createdAt to kolumny.
userId.
Zmiana modelu to proces, nie tylko dopisanie pola
// 1. Schema: potrzebujemy opisu użytkownika
model User {
id String @id @default(uuid())
email String @unique
name String?
bio String? // nowe opcjonalne pole
}
// 2. Zmieniamy prawdziwą bazę przez migrację
npx.cmd prisma migrate dev --name add_user_bio
// 3. Odświeżamy typy klienta Prisma
npx.cmd prisma generate
Potem aktualizujesz DTO, serwis, endpoint i testy. To ważne:
schema.prisma jest opisem zamiaru, migracja zmienia
prawdziwą bazę, a generate tworzy typowanego klienta
do użycia w TypeScripcie.
Debugowanie, narzędzia i mini-zadania
Backend rozwijasz małymi pętlami: uruchom, wyślij request, przeczytaj odpowiedź, obejrzyj dane, dopisz test.
# logi aplikacji
npm.cmd run start:dev
# status bazy i kontenera
docker compose ps
docker compose logs -f postgres
# wygodny podgląd danych w przeglądarce
npx.cmd prisma studio
Mini-zadania
-
Dodaj do User opcjonalne pole
bio, wykonaj migrację i zwróć je w publicznym profilu. -
Dodaj
PATCH /users/mez DTO zawierającym tylkonameibio. - Napisz test: request bez JWT do chronionego endpointu zwraca 401.
Mini-słowniczek
Endpoint — adres i metoda API. Payload — dane w requestcie lub tokenie. ORM — narzędzie mapujące obiekty kodu na dane bazy, tu Prisma. Migracja — wersjonowana zmiana struktury bazy. Hash — jednokierunkowy wynik funkcji, używany do bezpiecznego przechowywania haseł i refresh tokenów.
Utwórz projekt NestJS od zera
Zainstaluj Node.js 20+ i Docker Desktop. Następnie utwórz nowy katalog, a Nest CLI przygotuje pełny szkielet aplikacji oraz zainstaluje jej zależności.
mkdir nest-auth-api
cd nest-auth-api
npx @nestjs/cli new api
cd api
# Jeśli PowerShell blokuje npm.ps1, używaj wariantu .cmd:
npx.cmd @nestjs/cli new api
npm.cmd run start:dev
# Otwórz potem: http://localhost:3000
api. W kreatorze Nest wybierz menedżer pakietów
npm.
Uruchom PostgreSQL w Dockerze
W katalogu api utwórz plik
docker-compose.yml. Kontener daje powtarzalną
lokalną bazę. Dane są trzymane w volume, więc restart
kontenera ich nie usuwa.
services:
postgres:
image: postgres:17
container_name: nest-auth-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
docker compose up -d
docker compose ps
docker exec -it nest-auth-postgres psql -U app_user -d app_db
Zainstaluj i połącz Prisma 7 z bazą
Najpierw dodaj pakiety i zainicjuj Prisma. W tym projekcie
Prisma generuje klienta lokalnie do
generated/prisma, a połączenie dostarcza adapter
@prisma/adapter-pg.
npm.cmd install @prisma/client @prisma/adapter-pg pg
npm.cmd install -D prisma
npx.cmd prisma init
# .env
DATABASE_URL="postgresql://app_user:app_password@localhost:5432/app_db"
POSTGRES_USER="app_user"
POSTGRES_PASSWORD="app_password"
POSTGRES_DB="app_db"
generator client {
provider = "prisma-client"
output = "../generated/prisma"
moduleFormat = "cjs"
}
datasource db { provider = "postgresql" }
model User {
id String @id @default(uuid())
email String @unique
name String?
passwordHash String
refreshTokens RefreshToken[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RefreshToken {
id String @id @default(uuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
npx prisma migrate dev --name jwt, potem
npx prisma generate. Model
RefreshToken pozwala obsłużyć wiele urządzeń i
unieważniać konkretne sesje.
Ułóż moduły odpowiedzialności
Logika użytkowników nie powinna mieszkać w auth. Auth korzysta z serwisu użytkowników, a oba moduły korzystają z Prisma.
npx.cmd nest g module prisma
npx.cmd nest g service prisma
npx.cmd nest g module users
npx.cmd nest g service users
npx.cmd nest g controller users
npx.cmd nest g module auth
npx.cmd nest g service auth
npx.cmd nest g controller auth
src/
auth/
dto/ login.dto.ts, register.dto.ts, refresh-token.dto.ts
guards/ jwt-access.guard.ts
strategies/ jwt-access.strategy.ts
auth.controller.ts
auth.service.ts
common/decorators/current-user.decorator.ts
prisma/prisma.service.ts
users/dto/create-user.dto.ts
users/users.service.ts
Twórz użytkownika i hashuj hasło
Do bazy trafia tylko passwordHash. Publiczna
odpowiedź zawsze pomija ten atrybut.
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: { email: dto.email, name: dto.name, passwordHash },
});
return toPublicUser(user);
- DTO waliduje e-mail i hasło o długości minimum 8 znaków.
-
Duplikat e-maila zwraca
409 Conflict. -
Żadna odpowiedź API nie zawiera
passwordHash.
Rejestracja i logowanie zwracają parę tokenów
Access token identyfikuje requesty. Refresh token służy wyłącznie do odnowienia sesji.
/auth/register
/auth/login
const isPasswordValid = await bcrypt.compare(
dto.password,
user.passwordHash,
);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid email or password');
}
return {
user: publicUser,
accessToken,
refreshToken,
};
15m) ogranicza szkody po wycieku.
Jak działają JWT, access token i refresh token?
JWT (JSON Web Token) to podpisany przez serwer ciąg znaków. Nie jest zaszyfrowanym sejfem na dane użytkownika: jego zawartość można odczytać, ale nie można jej zmienić bez znajomości sekretu użytego do podpisu.
Co znajduje się w tokenie?
JWT ma trzy części rozdzielone kropkami:
header.payload.signature. W tym projekcie payload
zawiera co najmniej sub (ID użytkownika) i
email. Refresh token ma dodatkowo unikalne
jti, aby każda sesja miała własną tożsamość.
{
"sub": "uuid-uzytkownika",
"email": "wiktor@example.com",
"iat": 1780000000,
"exp": 1780000900
}
passwordHash, danych karty
ani innych sekretów. Payload można odczytać po stronie klienta bez
klucza.
Dlaczego są dwa tokeny?
Authorization: Bearer <accessToken>.
POST /auth/refresh, gdy access token wygasł.
Krótki access token ogranicza skutki jego wycieku. Refresh token pozwala użytkownikowi pozostać zalogowanym bez wymagania hasła co kilkanaście minut.
Przepływ od logowania do odświeżenia
1. POST /auth/login
- serwer porównuje hasło przez bcrypt.compare()
- zwraca accessToken i refreshToken
2. GET /auth/me
Authorization: Bearer <accessToken>
- guard sprawdza podpis i exp access tokenu
- strategy ustawia request.user = { id, email }
3. Access token wygasł
- API zwraca 401 Unauthorized
- klient wywołuje POST /auth/refresh z refreshTokenem
- API zwraca nową parę tokenów
- klient ponawia pierwotny request
Co znaczy „rotacja refresh tokenu”?
Podczas /auth/refresh backend nie tylko wydaje nowe
tokeny. Najpierw weryfikuje podpis JWT, sprawdza datę wygaśnięcia,
porównuje przesłany token z hashem w tabeli
RefreshToken, ustawia revokedAt starego
rekordu i zapisuje hash nowego tokenu. Stary refresh token nie
może zostać użyty ponownie.
- Access token jest wysyłany do chronionych endpointów.
- Refresh token służy tylko do odnowienia sesji.
- W bazie zapisuję hash refresh tokenu, nigdy jego czystą wartość.
Zabezpiecz endpointy przez JWT Guard
Strategy wyciąga token z nagłówka, weryfikuje podpis i
umieszcza dane użytkownika w request.user. Guard
wpuszcza request do kontrolera tylko po sukcesie.
@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'),
});
}
validate(payload: { sub: string; email: string }) {
return { id: payload.sub, email: payload.email };
}
}
@UseGuards(JwtAccessGuard)
@Get('me')
me(@CurrentUser() user: AuthenticatedUser) {
return user;
}
/auth/me — nagłówek
Authorization: Bearer <accessToken>
Dodaj refresh tokeny z rotacją
Po loginie zapisujesz hash refresh tokenu w bazie. Gdy klient odświeża sesję, unieważniasz stary rekord i zapisujesz nowy.
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id, email: user.email, jti: randomUUID() },
{
secret: config.getOrThrow('JWT_REFRESH_SECRET'),
expiresIn: config.getOrThrow('JWT_REFRESH_EXPIRES_IN'),
},
);
await prisma.refreshToken.create({
data: {
tokenHash: await bcrypt.hash(refreshToken, 12),
userId: user.id,
expiresAt: refreshTokenExpiresAt,
},
});
Unikalne jti sprawia, że nowy refresh JWT różni się
od starego nawet wtedy, gdy oba powstaną w tej samej sekundzie.
/auth/refresh
/auth/logout
/auth/logout-all
- W bazie przechowywany jest wyłącznie hash refresh tokenu.
-
Refresh sprawdza podpis, wygaśnięcie, hash i
revokedAt. - Rotacja działa w transakcji: stary token jest unieważniany tylko raz.
Utrzymaj bezpieczeństwo testami
Testy jednostkowe używają mocka Prisma, a test HTTP uruchamia prawdziwy JWT strategy/guard bez zależności od Dockera.
npm.cmd test -- --runInBand
npm.cmd run test:e2e -- --runInBand
npm.cmd exec tsc -- --noEmit --incremental false
- Hasło jest hashowane, a e-mail unikalny.
- Login z błędnym hasłem zwraca 401.
- Refresh token nie może zostać użyty drugi raz.
-
/auth/mebez access tokenu zwraca 401.
Po stronie klienta użyj jednego API clienta
fetch nie odświeża tokenów automatycznie. Umieść
tę logikę w jednym miejscu i dopilnuj, aby wiele odpowiedzi
401 uruchamiało tylko jeden refresh.
let refreshPromise: Promise<void> | null = null;
async function ensureFreshTokens() {
if (!refreshPromise) {
refreshPromise = refreshTokens().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
W React Native przechowuj tokeny w Expo SecureStore, iOS Keychain lub Android Keystore — nie w zwykłym, niezaszyfrowanym storage.
Co dalej?
Masz bazę pod aplikację mobilną, SPA albo klasyczne API. Następne sensowne kroki to role i uprawnienia, reset hasła, rate limiting, Swagger oraz monitoring logowań.