Przejdź do treści

Backend dla frontendowca: auth, real-time i integracje

Druga część serii Backend dla frontendowca: SSE, WebSockets, polling, webhooki, integracje zewnętrzne, sesje, JWT, cookies, CSRF, rotacja refresh tokenów i MFA.

Maciej Sala

Founder StriveLab

18 min czytaniaOpublikowano 29 lipca 2025 (Aktualizacja 30 lipca 2026)

To są tematy, w których frontend i backend bardzo mocno na siebie wpływają. Wybór między SSE a WebSocketami zmienia sposób budowania . Źle obsłużony webhook potrafi podwoić zamówienie. Integracja bez ponowień i limitów zaczyna losowo psuć checkout. Niejasny model uwierzytelniania kończy się losowymi 401, problemami z cookie i debugowaniem, które wygląda jak szukanie błędu po omacku.

1. Real-time w backendzie: WebSocket, SSE i polling w aplikacji webowej

REST dobrze obsługuje klasyczny model „zapytaj i dostań odpowiedź”. Nie wystarcza jednak wtedy, gdy serwer ma sam poinformować użytkownika o zmianie: nowej wiadomości, statusie zadania, postępie generowania odpowiedzi AI albo aktualizacji dashboardu. Wtedy wchodzą , SSE i WebSockets.

Polling jako najprostsze rozwiązanie real-time

Polling jest najprostszy mentalnie: frontend pyta co kilka sekund, czy coś się zmieniło. Nie wymaga specjalnej infrastruktury, ale płacisz za to opóźnieniem i dodatkowymi żądaniami.

Code
setInterval(async () => {
  const updates = await fetch('https://api.example.com/notifications')
  // ...
}, 5000)

W przypadku long pollingu żądanie pozostaje otwarte, dopóki backend nie ma czego zwrócić.

Code
async function poll() {
  while (true) {
    const res = await fetch('https://api.example.com/notifications/wait', {
      signal,
    })
    if (res.ok) handleUpdate(await res.json())
  }
}

Long polling działa prawie wszędzie i bywa dobrym rozwiązaniem awaryjnym, ale w nowych projektach zwykle warto najpierw rozważyć SSE albo WebSockets.

Server-Sent Events (SSE) do komunikacji serwer klient

SSE to jednokierunkowy strumień serwer → klient. Działa na zwykłym HTTP i ma automatyczne wznawianie połączenia, więc świetnie pasuje do sytuacji, w których klient nie musi odsyłać wiadomości tym samym kanałem.

Code
// Frontend
const events = new EventSource('https://api.example.com/notifications/stream')
events.onmessage = (e) => {
  const data = JSON.parse(e.data)
  // ...
}
events.onerror = () => {
  // EventSource sam spróbuje się przepiąć
}
Code
// Backend (Express)
app.get('/notifications/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache, no-transform')
  res.setHeader('X-Accel-Buffering', 'no')
  res.flushHeaders()
 
  let eventId = Date.now()
  const interval = setInterval(() => {
    eventId += 1
    res.write(`id: ${eventId}\n`)
    res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`)
  }, 15000)
 
  req.on('close', () => clearInterval(interval))
})

Idealne dla: powiadomień, feedów, strumieniowania AI i pokazywania postępu na żywo.

Na co uważać: przy HTTP/1.x przeglądarki mają niski limit równoległych połączeń do jednej domeny, więc wiele otwartych kart z SSE może zablokować kolejne strumienie. Przy HTTP/2 limit jest negocjowany jako liczba strumieni, ale nadal zależy od konfiguracji klienta, serwera i proxy. Proxy nie może buforować odpowiedzi, a serwer powinien okresowo wysyłać komentarz lub zdarzenie, aby połączenie nie wygasło.

Natywny EventSource nie pozwala ustawić własnego nagłówka Authorization. Dla aplikacji webowej najprostsze jest zwykle uwierzytelnienie przez bezpieczne cookie tego samego originu. Token w adresie URL powinien być krótkotrwały, jednorazowy i używany tylko wtedy, gdy nie da się zastosować cookie, ponieważ adres może trafić do logów, historii i narzędzi monitorujących. Jeżeli klient ma odzyskiwać pominięte dane po ponownym połączeniu, backend musi zapisywać identyfikatory zdarzeń i obsłużyć nagłówek Last-Event-ID. Samo automatyczne ponowne połączenie nie gwarantuje odtworzenia wiadomości.

WebSockets w aplikacji webowej

WebSocket tworzy dwukierunkowy, trwały kanał. To już nie jest zwykłe żądanie i odpowiedź, tylko osobny protokół (ws:// / wss://) z uzgodnieniem połączenia opartym na HTTP Upgrade.

Code
// Frontend
const ws = new WebSocket('wss://api.example.com/chat')
 
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', room: 'general' }))
ws.onmessage = (e) => handleMessage(JSON.parse(e.data))
ws.onclose = () => reconnect()
Code
// Backend (ws library)
import { WebSocket, WebSocketServer } from 'ws'
 
const wss = new WebSocketServer({
  port: 8080,
  maxPayload: 64 * 1024,
})
 
wss.on('connection', (socket, request) => {
  const user = authenticateUpgradeRequest(request)
  if (!user) return socket.close(1008, 'Unauthorized')
 
  socket.on('message', (data) => {
    const message = parseAndValidateMessage(data)
    if (!canPublishToRoom(user, message.room)) {
      return socket.close(1008, 'Forbidden')
    }
 
    for (const client of clientsInRoom(message.room)) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message))
      }
    }
  })
})

Idealne dla: chatów, gier, współpracy w czasie rzeczywistym (Figma, Notion, Google Docs) i notowań na żywo.

WebSocket, SSE czy polling: co wybrać?

Jeżeli potrzebujesz tylko wysyłać aktualizacje z serwera do klienta, SSE jest prostsze. Jeżeli klient i serwer mają rozmawiać w obie strony w czasie rzeczywistym, wybierz WebSockets.

ScenariuszRozwiązanie
Powiadomienia "nowy komentarz"SSE
Chat 1-on-1 / pokojeWebSockets
Strumieniowanie AISSE
Gra w czasie rzeczywistymWebSockets
Status zadania w tleSSE / polling
Kolaboracja na dokumencie (CRDT)WebSockets

Popularne biblioteki i usługi: Socket.IO, Pusher, Ably, Soketi i Cloudflare Durable Objects. Socket.IO korzysta z własnego protokołu opartego na Engine.IO, może zacząć od HTTP long pollingu i przejść na WebSocket. Nie jest więc bezpośrednio zgodne z surowym klientem WebSocket.

Wyzwania real-time: skalowanie, ponowne połączenie i autoryzacja

  • Skalowanie horyzontalne wymaga wspólnej dystrybucji zdarzeń. Redis pub/sub lub NATS pasują do szybkiego fan-outu. Kafka lepiej sprawdza się jako trwały dziennik zdarzeń, gdy liczy się odtwarzanie i wielu niezależnych konsumentów.
  • Autoryzację trzeba sprawdzać dla każdego kanału i komunikatu. Samo uwierzytelnienie podczas handshake nie daje prawa do wejścia do dowolnego pokoju ani wykonania każdej operacji.
  • Sticky sessions zależą od transportu i architektury. Są często potrzebne przy rozwiązaniu awaryjnym opartym na long pollingu lub stanie trzymanym lokalnie. Po zestawieniu zwykłego WebSocketu połączenie i tak pozostaje przy jednej instancji.
  • Ponowne połączenie wymaga strategii wznawiania danych. W SSE pomaga Last-Event-ID, ale backend nadal musi przechowywać zdarzenia i umieć zwrócić brakujący zakres.
  • Heartbeat i limit czasu wykrywają martwe połączenia. Sam status „open” nie dowodzi, że klient nadal ma łączność.
  • Backpressure i limity chronią pamięć serwera. Ogranicz rozmiar wiadomości, liczbę połączeń i tempo komunikatów.
  • Origin, schemat wiadomości i uprawnienia wymagają walidacji. WebSocket omija część typowego cyklu middleware HTTP, dlatego każdą wiadomość trzeba traktować jak niezaufane dane wejściowe.
  • Proxy i mają własne limity. Przed wyborem transportu sprawdź czas trwania połączeń i zachowanie , load balancera oraz platformy hostingowej.

2. Webhooki w backendzie: bezpieczna obsługa zdarzeń z zewnętrznych systemów

to odwrócone wywołanie API. Zamiast co minutę pytać system płatności „czy faktura jest już opłacona?”, pozwalasz mu samemu poinformować Twoją aplikację, gdy coś się wydarzy. To prosty wzorzec, ale wymaga dyscypliny: podpisy, ponowienia, idempotencja i szybka odpowiedź są tutaj krytyczne.

Code
Stripe → POST https://twoja-app.com/webhooks/stripe
Body: {
  "type": "payment_intent.succeeded",
  "data": { "amount": 10000, "currency": "pln" }
}

Trzy zasady bezpiecznych webhooków

1. Weryfikuj podpis

Dostawca podpisuje treść żądania (najczęściej HMAC-SHA256 z sekretem). Bez weryfikacji ktoś może udawać Stripe i oznaczać zamówienia jako opłacone:

Code
import Stripe from 'stripe'
 
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }), // raw body, nie JSON-parsed
  (req, res) => {
    const sig = req.headers['stripe-signature']
    let event
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET,
      )
    } catch {
      return res.status(400).send('Invalid signature')
    }
    // Po trwałym zapisie uruchom obsługę asynchroniczną
    res.json({ received: true })
  },
)

2. Odpowiadaj szybko

Webhook to nie miejsce na ciężką logikę. Dostawca zwykle oczekuje szybkiej odpowiedzi 2xx. Przekroczenie limitu czasu albo inny status może spowodować ponowienie. Potwierdź odbiór dopiero po trwałym zapisaniu zdarzenia, a logikę biznesową wykonaj asynchronicznie.

Code
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const event = verifyStripeSignature(req)
 
    await db.transaction(async (tx) => {
      await tx.webhookEvent.insertOrIgnore({
        provider: 'stripe',
        eventId: event.id,
        type: event.type,
        payload: req.body,
        status: 'pending',
      })
      await tx.outbox.insertOrIgnore({
        key: `stripe:${event.id}`,
        topic: 'process-stripe-event',
        payload: { eventId: event.id },
      })
    })
 
    res.status(200).end()
  },
)

3. Idempotency chroni przed duplikatami

Ten sam webhook może przyjść kilka razy. Dodaj unikalny indeks złożony, na przykład dla provider i eventId. Samo sprawdzenie findUnique, a potem osobny create, ma wyścig między równoległymi żądaniami. O idempotencji powinno rozstrzygać ograniczenie w bazie.

Code
CREATE UNIQUE INDEX webhook_event_provider_id
ON webhook_event (provider, event_id);

Nie oznaczaj zdarzenia jako przetworzonego przed wykonaniem logiki biznesowej. Worker powinien zmienić status z pending na processing, zapisać processed dopiero po sukcesie i pozostawić błąd oraz licznik prób po niepowodzeniu. Dzięki temu ponowienie pracy nie zgubi płatności.

Bezpieczny przepływ webhooka krok po kroku

Najbezpieczniejszy schemat wygląda tak:

  1. Odbierz raw body, zanim parser JSON zmieni treść żądania.
  2. Zweryfikuj podpis i świeżość timestampu.
  3. Zapisz event.id, typ, treść żądania i status pending w bazie.
  4. W tej samej transakcji dodaj rekord outbox, który niezawodnie zasili kolejkę.
  5. Jeśli event.id już istnieje, nie twórz drugiego zadania.
  6. Odpowiedz szybko 2xx, ale dopiero po zatwierdzeniu transakcji.
  7. Worker przetwarza zdarzenie i atomowo zapisuje wynik operacji biznesowej.
  8. Osobny proces ponawia rekordy pending i błędy przejściowe.

To rozdziela dwa problemy: dostarczenie zdarzenia i przetworzenie zdarzenia. Dzięki temu ponowienie dostawcy nie tworzy duplikatów, a awaria Twojej logiki biznesowej nie musi oznaczać utraty informacji o płatności.

Lokalne testowanie webhooków przez tunel publiczny

Webhook nie dojdzie bezpośrednio na localhost:3000, bo zewnętrzny system musi mieć publiczny adres, pod który może wysłać żądanie. Do pracy lokalnej używa się tuneli:

  • ngrok daje publiczny URL. Uruchom ngrok http 3000.
  • Stripe CLI przekazuje zdarzenia na localhost. Użyj stripe listen --forward-to localhost:3000/webhooks/stripe.
  • Cloudflare Tunnel jest alternatywą dla ngrok.
  • smee.io przekazuje webhooki z GitHuba.

Popularne źródła webhooków: Stripe, GitHub, CMS i CRM

  • Stripe obsługuje zdarzenia płatności, na przykład payment_intent.succeeded i invoice.paid.
  • GitHub wysyła zdarzenia repozytorium, między innymi push, pull_request i issues.
  • Resend i SendGrid raportują dostarczenie wiadomości, odbicia oraz skargi.
  • Slack obsługuje komendy i zdarzenia aplikacji.
  • Twilio raportuje statusy SMS-ów i połączeń.
  • Clerk i Auth0 informują o cyklu życia użytkownika.
  • Vercel wysyła zdarzenia dotyczące wdrożeń.

3. Integracje API z zewnętrznymi usługami

Webhooki są integracją przychodzącą. Druga połowa tematu to integracje wychodzące: Twoja aplikacja woła Stripe, Slacka, OpenAI, system ERP, bramkę SMS albo CRM. Dla frontendu często wygląda to jak jeden przycisk „Wyślij”, ale po stronie backendu trzeba obsłużyć wolne odpowiedzi, limity, ponowienia i częściowe awarie.

Najważniejsza zasada: zewnętrzne API nie jest częścią Twojej aplikacji. Może zwolnić, zwrócić 429, zmienić komunikat błędu, mieć przerwę techniczną albo odpowiedzieć po czasie, gdy użytkownik już zamknął ekran.

Kontrakt integracji API: limity czasu, ponowienia i rate limit

Każda poważniejsza integracja powinna mieć:

  • Jawny limit czasu żądania. Połączenie nie może wisieć w nieskończoność.

  • Ponawiaj tylko błędy przejściowe. Stosuj exponential backoff z jitterem dla błędów sieciowych, 408, 429 i wybranych 5xx.

  • Obsługę limitów dostawcy. Respektuj Retry-After, limity konta i limity konkretnego endpointu.

  • Klucz idempotencji dla mutacji. Jest szczególnie ważny przy płatnościach, zamówieniach i tworzeniu zasobów.

  • Mapowanie błędów na własny kontrakt. Nie pokazuj użytkownikowi surowej odpowiedzi dostawcy.

  • Logowanie identyfikatora żądania dostawcy. Ułatwia wsparcie i debugowanie.

  • Kolejkę dla wolnych operacji. Użyj jej, gdy zadanie jest drogie albo nie musi zakończyć się w tym samym żądaniu.

Przykład ponawiania dla bezpiecznego żądania GET:

Code
function retryAfterMs(response) {
  const value = response.headers.get('retry-after')
  if (!value) return null
 
  const seconds = Number(value)
  if (Number.isFinite(seconds)) return seconds * 1000
 
  const date = Date.parse(value)
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now())
}
 
async function fetchWithRetry(url, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(url, {
        method: 'GET',
        signal: AbortSignal.timeout(5000),
      })
 
      const retryable = [408, 429, 500, 502, 503, 504].includes(response.status)
      if (!retryable || attempt === maxAttempts) return response
 
      const backoff = 250 * 2 ** (attempt - 1)
      const jitter = Math.random() * 200
      const delay = retryAfterMs(response) ?? backoff + jitter
      await new Promise((resolve) => setTimeout(resolve, delay))
    } catch (error) {
      if (attempt === maxAttempts) throw error
 
      const delay = 250 * 2 ** (attempt - 1) + Math.random() * 200
      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }
}

Nie ponawiaj automatycznie POST, PATCH ani innych operacji zmieniających stan, jeżeli API nie zapewnia idempotencji. Timeout nie mówi, czy dostawca wykonał operację. Odpowiedź mogła zginąć już po utworzeniu płatności lub zamówienia.

Kolejka zadań zamiast synchronicznej odpowiedzi w integracjach

Jeżeli użytkownik musi natychmiast zobaczyć wynik, backend może poczekać na odpowiedź dostawcy. Jeżeli operacja jest ciężka albo podatna na awarie, lepiej zapisać zadanie i przetworzyć je asynchronicznie.

ScenariuszLepszy wzorzec
Sprawdzenie kuponu w checkoutsynchroniczna odpowiedź
Wysłanie e-maila po rejestracjikolejka
Import 20 000 rekordów z CRMkolejka + status postępu
Wystawienie faktury po płatnościwebhook + kolejka
Synchronizacja katalogu produktówcron / zadanie cykliczne + ponowienia
Powiadomienie UI o zakończeniu zadaniaSSE, WebSocket albo polling

Dead-letter queue i monitoring nieudanych integracji API

Ponawianie nie może trwać bez końca. Po kilku nieudanych próbach zadanie powinno trafić do dead-letter queue albo statusu failed, który ktoś może przejrzeć i uruchomić ponownie ręcznie. Bez tego integracja „czasem nie działa”, ale nikt nie wie, które rekordy utknęły.

Dla frontendowca ważny jest też model stanu:

  • queued oznacza zadanie przyjęte,
  • processing oznacza pracę backendu,
  • succeeded oznacza zakończenie operacji,
  • failed_retryable oznacza problem przejściowy i kolejną próbę,
  • failed_final oznacza potrzebę działania użytkownika lub wsparcia.

To pozwala zbudować UI, który nie udaje, że wszystko dzieje się synchronicznie.

4. Autentykacja i autoryzacja w backendzie

To są dwa różne pojęcia, które często wrzuca się do jednego worka. Autentykacja odpowiada na pytanie „kim jesteś?”, a autoryzacja na pytanie „co możesz zrobić?”. Frontend odczuwa oba mechanizmy przez logowanie, wygasające sesje, ukrywanie elementów UI i błędy 401 / 403, ale prawdziwa decyzja zawsze musi zapaść na backendzie.

StatusCo oznaczaReakcja UI
401Nie wiadomo, kim jesteśSpróbuj odświeżyć sesję albo pokaż login
403Wiadomo, kim jesteś, ale nie masz dostępuPokaż brak uprawnień, nie przekierowuj na login
419 / 440Kody niestandardowe używane przez część frameworkówObsłuż tylko wtedy, gdy definiuje je kontrakt API
429Za dużo prób logowania lub żądańPokaż cooldown i nie spamuj ponowieniami

401, 403 i 429 są standardowymi kodami HTTP. 419 oraz 440 to konwencje frameworków i dostawców, dlatego frontend nie powinien zakładać ich znaczenia bez dokumentacji własnego API.

Autentykacja to weryfikacja tożsamości użytkownika.

Metody:

  • Hasło jest tradycyjną metodą logowania.
  • OpenID Connect obsługuje logowanie przez zewnętrznego dostawcę. Sam deleguje autoryzację do zasobów i nie jest protokołem uwierzytelniania.
  • Magic link pozwala zalogować się przez wiadomość e-mail.
  • Passkeys używają FIDO2 i WebAuthn. Użytkownik zwykle odblokowuje poświadczenie biometrią, PIN-em albo kluczem sprzętowym.

W logowaniu przez dostawcę używaj Authorization Code Flow z PKCE. Parametry transakcji muszą być unikalne. Waliduj state, a w OpenID Connect także nonce, issuer, audience i podpis ID tokenu. Nie buduj tego mechanizmu ręcznie, gdy biblioteka dostawcy wykonuje pełną walidację protokołu.

Bezpieczne hasła, reset konta i ochrona logowania

Hasła zapisuj jako wolne, solone hashe, nigdy jako tekst jawny ani odwracalne szyfrogramy. OWASP rekomenduje Argon2id dla nowych systemów z minimalnymi parametrami 19 MiB pamięci, 2 iteracji i równoległości 1. scrypt jest alternatywą, gdy Argon2id nie jest dostępny. bcrypt z work factor co najmniej 10 traktuj jako rozwiązanie dla starszych systemów i pamiętaj o limicie 72 bajtów wejścia.

Sam hashing nie zamyka tematu. Endpoint logowania powinien mieć rate limiting, komunikaty nieujawniające, czy konto istnieje, oraz monitoring nietypowych prób. Po logowaniu i każdej zmianie poziomu uprawnień regeneruj identyfikator sesji, aby ograniczyć session fixation. Reset hasła, zmiana adresu e-mail i odzyskiwanie konta wymagają ponownej autentykacji, audytu oraz możliwości unieważnienia aktywnych sesji.

Autoryzacja w API: role, uprawnienia i decyzje backendu

Autoryzacja to sprawdzanie uprawnień. UI może ukryć przycisk „Usuń użytkownika”, ale backend i tak musi sprawdzić rolę przy samym żądaniu.

Code
// Middleware autoryzacji
function requireAdmin(req, res, next) {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' })
  }
  next()
}
 
app.delete('/users/:id', requireAdmin, deleteUser)

JWT (JSON Web Token) w API i aplikacji webowej

JWT to format tokenu, a nie magiczny zamiennik sesji. Często sprawdza się w API i integracjach między usługami, ale w klasycznych aplikacjach webowych sesje nadal bywają prostsze operacyjnie. Podpis JWT zapewnia integralność, nie poufność. Treść zwykłego JWS można odczytać, dlatego nie umieszczaj w nim sekretów.

Code
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  ← Header
eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIn0.  ← Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← Signature

Przykładowy przepływ:

  1. Użytkownik loguje się → serwer tworzy krótko żyjący token
  2. Frontend trzyma go bezpiecznie (np. w pamięci) albo korzysta z mechanizmu ciasteczek (cookies)
  3. Przeglądarka wysyła token w nagłówku lub cookie
  4. Serwer weryfikuje podpis albo identyfikator sesji przy każdym żądaniu

Sesje serwerowe w Redisie lub bazie danych

W sesjach serwerowych stan jest przechowywany w bazie lub Redisie.

Code
1. Użytkownik loguje się → serwer tworzy sesję w bazie/Redis
2. Serwer zwraca session ID w cookie
3. Przeglądarka wysyła cookie automatycznie
4. Serwer sprawdza session ID w bazie

JWT vs sesje: kompromisy architektoniczne

CechaSesjaJWT
StanDane sesji są w Redisie lub bazieRoszczenia są w tokenie
UnieważnienieZwykle natychmiastowe po usunięciu sesjiWymaga krótkiego TTL, listy cofnięć lub wersji
SkalowanieWymaga wspólnego magazynu sesjiWalidacja może działać bez odczytu z bazy
Rozmiar żądaniaZwykle mały, losowy identyfikatorCały token jest wysyłany z każdym żądaniem
Stan operacyjnyJawnie przechowywany po stronie serweraCzęsto wraca przy rotacji i unieważnianiu
Wiele domenWymaga poprawnej konfiguracji cookie i CORSWymaga bezpiecznego transportu, magazynu i CORS

Nie wybieraj JWT tylko ze względu na skalowanie. Wspólny magazyn sesji jest prostym i dojrzałym rozwiązaniem, a JWT przestaje być całkowicie bezstanowy, gdy dodasz natychmiastowe wylogowanie, rotację refresh tokenów albo wykrywanie kradzieży.

W klasycznej aplikacji webowej sesja w HttpOnly cookie jest często prostsza niż własny system JWT. JWT ma sens, gdy odbiorcy muszą lokalnie weryfikować podpisany token lub gdy architektura ma jasno zdefiniowane issuer, audience, krótki TTL i mechanizm cofania dostępu.

Cookies w autentykacji: HttpOnly, Secure i SameSite

Cookies wyglądają niepozornie, ale przy autentykacji każdy flag ma znaczenie. Jedna błędna konfiguracja potrafi zrobić różnicę między rozsądnym modelem bezpieczeństwa a tokenem, który wycieka przez XSS albo jest wysyłany w niepożądanym kontekście.

Code
Set-Cookie: __Host-session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
FlagaCo robiWażne ograniczenie
HttpOnlyBlokuje dostęp przez document.cookieXSS nadal może wysyłać żądania w imieniu użytkownika
SecureWysyła cookie wyłącznie po HTTPSNie zastępuje HttpOnly ani ochrony przed XSS
SameSite=StrictBlokuje cookie w żądaniach cross-siteMoże utrudnić wejście do aplikacji z zewnętrznego linku
SameSite=LaxDopuszcza część nawigacji top-level z bezpieczną metodąTo ochrona warstwowa, nie pełny zamiennik tokenu CSRF
SameSite=NonePozwala wysyłać cookie cross-site i wymaga SecurePotrzebne między innymi w części scenariuszy osadzania
PathOgranicza adresy, do których przeglądarka wysyła cookieNie jest granicą bezpieczeństwa
DomainRozszerza wysyłanie cookie na wskazaną domenę i subdomenyPomijaj, jeśli subdomeny nie muszą dostawać cookie
__Host-Wymaga Secure, Path=/ i braku DomainUtrudnia nadpisanie cookie przez inną subdomenę lub ścieżkę
Max-Age / ExpiresOkreśla czas przechowywania cookieSesja i tak musi wygasać oraz być unieważniana po stronie API

Nie zakładaj, że brak SameSite zawsze oznacza identyczne zachowanie. Część przeglądarek stosuje wtedy wariant Lax, który przez krótki czas może dopuścić także niektóre żądania POST. Ustaw wartość jawnie.

CSRF w aplikacjach webowych z cookies

Jeśli używasz cookies do uwierzytelniania, musisz rozumieć (Cross-Site Request Forgery). Atakujący nie musi znać tokena użytkownika. Wystarczy, że użytkownik jest zalogowany, a przeglądarka automatycznie dołączy cookie do żądania wysłanego z obcej strony.

Code
<!-- evil.com -->
<form action="https://bank.example.com/api/transfer" method="POST">
  <input name="to" value="atakujacy" />
  <input name="amount" value="10000" />
</form>
<script>
  document.forms[0].submit()
</script>

Przeglądarka sama dołączy cookie uwierzytelniające do tego POST-a, jeśli SameSite na to pozwala.

Obrona powinna składać się z kilku warstw:

  1. Ustaw SameSite=Lax lub Strict na cookie uwierzytelniającym. Traktuj to jako ochronę dodatkową, ponieważ pojęcie same-site obejmuje także inne subdomeny tej samej domeny rejestrowalnej.
  2. Stosuj token synchronizer dla sesji stanowych. Backend zapisuje sekret w sesji i porównuje go z wartością przesłaną jawnie przez frontend.
  3. Dla rozwiązania bezstanowego użyj podpisanego double-submit cookie. Token musi być związany z konkretną sesją. Naiwne porównanie dwóch wartości cookie jest podatne na cookie injection.
  4. Sprawdzaj Origin lub Referer dla operacji zmieniających stan. W nowoczesnych przeglądarkach możesz dodatkowo wykorzystać nagłówki Fetch Metadata, na przykład Sec-Fetch-Site.
  5. Nie zmieniaj stanu przez GET. Metody uznawane za bezpieczne nie powinny wykonywać przelewów, usuwać danych ani zmieniać ustawień.

Token trzymany w localStorage i ręcznie dodawany do nagłówka nie jest automatycznie wysyłany przez przeglądarkę, co ogranicza klasyczny CSRF. Nadal może zostać odczytany przez . Cookie HttpOnly ogranicza bezpośrednią kradzież tokena przez JavaScript, ale XSS wciąż może wysyłać uwierzytelnione żądania z poziomu aplikacji.

Nie ma jednego „najlepszego” miejsca na token dla każdej aplikacji. Dla klasycznej aplikacji webowej najczęściej wygrywa cookie HttpOnly + Secure + SameSite=Lax/Strict. Dla korzystającej z OAuth coraz częściej spotkasz wariant: krótko żyjący access token w pamięci aplikacji i refresh token w bezpiecznym, HttpOnly cookie albo obsługę uwierzytelniania przez BFF.

Refresh token rotation i wykrywanie kradzieży tokena

Access token powinien mieć krótki czas życia, a refresh token może działać dłużej. Konkretne wartości zależą od ryzyka aplikacji. Poniższe 15 minut i 7 dni są tylko przykładem.

Code
1. Login → access (15 min) + refresh (7 dni)
2. Wywołanie API z access tokenem
3. 401 Unauthorized → frontend wywołuje POST /auth/refresh z refresh tokenem
4. Backend zwraca nowy access + NOWY refresh (rotacja)
5. Stary refresh natychmiast unieważniony

Skuteczna rotacja musi przechowywać relację między tokenami. Jeżeli ktoś ponownie użyje starego refresh tokena, serwer wykrywa ponowne użycie i unieważnia aktywną rodzinę tokenów powiązaną z tym grantem. Nie musi to oznaczać wylogowania ze wszystkich urządzeń użytkownika. Zakres unieważnienia zależy od modelu sesji i implementacji. Dla klientów publicznych OAuth Security BCP wymaga refresh tokenów związanych z nadawcą albo rotacji wykrywającej ponowne użycie.

Z perspektywy frontendu są tu trzy ważne reguły:

  1. Nie rób nieskończonej pętli odświeżania. Jeśli refresh zwróci 401, wyczyść stan i pokaż login.
  2. Zablokuj równoległe odświeżenia. Gdy pięć żądań naraz dostanie 401, tylko jedno powinno odświeżać token, a reszta poczekać na wynik.
  3. Rozróżniaj brak uwierzytelnienia od braku uprawnień. 401 może uruchomić refresh, ale 403 powinien pokazać brak dostępu.

MFA, TOTP, WebAuthn i passkeys jako drugi czynnik logowania

Hasło to wiedza, czyli coś, co użytkownik zna. MFA dodaje coś, co użytkownik ma, albo cechę, którą potwierdza lokalnie.

  • TOTP generuje krótkie kody czasowe. Korzystają z niego między innymi aplikacje uwierzytelniające zgodne z RFC 6238.
  • WebAuthn i passkeys używają kryptografii klucza publicznego. Poświadczenie może znajdować się w kluczu sprzętowym lub uwierzytelniaczu platformowym.
  • SMS jest wygodny, ale słabszy. Jest podatny między innymi na SIM swap i phishing.
  • Powiadomienia push wymagają ochrony przed zmęczeniem MFA. Sam przycisk zatwierdzenia bez kontekstu może prowadzić do przypadkowej akceptacji.

Passkeys są odporne na typowy phishing, ponieważ poświadczenie jest związane z domeną. Serwer przechowuje klucz publiczny, a klucz prywatny pozostaje na urządzeniu albo w zaufanym menedżerze poświadczeń. Biometria odblokowuje uwierzytelniacz lokalnie i nie jest wysyłana do serwera. W praktyce trzeba przewidzieć kody odzyskiwania, rejestrację drugiego urządzenia, bezpieczne usuwanie poświadczeń oraz procedurę odzyskania konta.

Gotowe rozwiązania do auth: Auth.js, Clerk, Supabase Auth, Auth0 i Okta

Autoryzacja i uwierzytelnianie to obszary, w których samodzielne pisanie kodu od zera rzadko kończy się dobrze, dlatego w środowisku produkcyjnym zawsze warto stawiać na sprawdzone narzędzia lub przynajmniej dojrzałe, przetestowane biblioteki.

  • jest otwartym rozwiązaniem popularnym w Next.js.

  • Clerk dostarcza zarządzany auth i gotowe interfejsy. Koszt zależy od skali i używanych funkcji.

  • Supabase Auth integruje się z pozostałymi usługami Supabase.

  • Auth0 i Okta oferują rozbudowane funkcje dla organizacji.

  • Lucia jest obecnie zasobem edukacyjnym do implementowania sesji, a nie biblioteką zalecaną do nowych wdrożeń.

  • jest rozwiązaniem niezależnym od jednego frameworka.

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.
React

Pozostałe części serii

Często zadawane pytania

Kiedy WebSockets a kiedy SSE?

SSE wybierz dla jednokierunkowego strumienia serwer → klient: powiadomienia, strumieniowanie AI, postęp na żywo i feedy. Działa na zwykłym HTTP, ma automatyczne wznawianie połączenia i jest prostsze w utrzymaniu. Pamiętaj jednak o limitach połączeń przy HTTP/1.x i o tym, że natywny EventSource nie pozwala łatwo dodawać własnych nagłówków. WebSockets wybierz dla komunikacji dwukierunkowej: chatów, gier, współpracy na dokumencie i scenariuszy, w których klient też stale wysyła zdarzenia do serwera.

Jak debugować webhooki lokalnie?

Webhook z systemu zewnętrznego nie dojdzie bezpośrednio na localhost. Użyj tunelu publicznego, np. ngrok, Cloudflare Tunnel albo narzędzia dostawcy, np. Stripe CLI z komendą stripe listen --forward-to localhost:3000/webhooks/stripe. Weryfikuj podpis, zapisuj zdarzenia trwale i idempotentnie, a dopiero potem odpowiadaj szybko 200 OK. Cięższą logikę uruchamiaj przez outbox i kolejkę.

Jaka jest różnica między JWT a sesjami?

Sesje przechowują stan na serwerze, np. w Redisie albo bazie, a klient dostaje tylko ID w cookie. JWT przechowuje dane w podpisanym tokenie, więc serwer może czasem zweryfikować go bez odczytu z bazy. Sesje ułatwiają natychmiastowe unieważnianie. JWT bywa przydatny w API i komunikacji między usługami, ale nadal wymaga planu na wygasanie, rotację i cofanie dostępu. Wybór zależy od modelu zaufania, a nie od tego, czy klientem jest aplikacja webowa lub mobilna.

Co to jest CSRF i jak się przed nim chronić?

CSRF to atak, w którym obca strona wysyła żądanie do Twojej aplikacji, wykorzystując fakt, że przeglądarka automatycznie dołącza cookie zalogowanego użytkownika. Obrona to SameSite=Lax lub Strict na cookie uwierzytelniającym, token CSRF dla żądań zmieniających stan oraz sprawdzanie nagłówków Origin lub Referer po stronie backendu.

Jak bezpiecznie przechowywać hasła?

Nigdy nie przechowuj haseł w postaci jawnej. Używaj algorytmów przeznaczonych do haseł. OWASP rekomenduje Argon2id, co najmniej 19 MiB pamięci, 2 iteracje i równoległość 1. bcrypt z work factor co najmniej 10 pozostaje rozwiązaniem dla starszych systemów, gdy Argon2id i scrypt nie są dostępne. Parametry zawsze testuj na własnej infrastrukturze.

Jak obsługiwać integracje z zewnętrznymi API?

Każda integracja powinna mieć limity czasu, ponowienia z exponential backoff, obsługę rate limitów, klucz idempotencji przy operacjach mutujących i logowanie identyfikatora żądania dostawcy. Ciężkie lub niepewne operacje warto przerzucać do kolejki, żeby awaria zewnętrznego API nie blokowała UI ani głównego żądania użytkownika.

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 Backend

Czytaj dalej

Zobacz więcej wpisów
Bezpieczna autentykacja w React: Gdzie trzymać token?

Nie ma prostej odpowiedzi na to, gdzie trzymać token w aplikacji React: to ciągły kompromis między XSS a CSRF. LocalStorage i sessionStorage są podatne na kradzież przez XSS, a z kolei ciasteczka z flagą httpOnly skutecznie blokują dostęp skryptom JS, ale wystawiają aplikację na ataki CSRF. Trzymanie tokenu w pamięci RAM chroni przed jednym i drugim, ale znika przy każdym odświeżeniu strony...

Maciej Sala

Maciej Sala

Founder StriveLab

Jak wdrożyć Server-Sent Events (SSE) w Next.js?

To przewodnik wdrożeniowy, w którym krok po kroku budujemy powiadomienia server-to-client w Next.js. Szersze porównanie strategii znajdziesz w artykule WebSockets, Server-Sent Events czy Polling? /blog/websockets-vs-server-sent-events-vs-polling-kiedy-ktory/ .

Maciej Sala

Maciej Sala

Founder StriveLab

Backend dla frontendowca: serwer, bazy danych i API

Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej okazuje się, że jakość UI zależy od tego, co dzieje się po drugiej stronie API . Jak backend paginuje dane, jak zwraca błędy, jak trzyma sesję, co robi po przekroczeniu limitu czasu i czy potrafi przyjąć większy ruch.

Maciej Sala

Maciej Sala

Founder StriveLab