Uwierzytelnianie w FastAPI: logowanie, JWT i ochrona endpointów

Piotr Dul

21-10-2026

(aktualizacja: 21-10-2026)

Jak zabezpieczyć API w FastAPI: różnica między JWT a OAuth2, hashowanie haseł, endpoint logowania, ochrona tras przez Depends oraz wywołania z JavaScriptu. Z działającym kodem i testami błędów 401.

Uwierzytelnianie w FastAPI: logowanie, JWT i ochrona endpointów
W FastAPI uwierzytelnianie robisz w trzech krokach: hashujesz hasła (na przykład Argon2), po poprawnym logowaniu wystawiasz podpisany token JWT, a chronione endpointy sprawdzają go przez `Depends(OAuth2PasswordBearer)`. JWT to tylko format tokenu, a OAuth2 to protokół wymiany danych logowania na token, więc nie są to zamienniki. Payload tokenu nie jest szyfrowany, dlatego nie wolno w nim trzymać żadnych tajnych danych.

Dopóki API zwraca publiczne dane, wystarczy kilka endpointów. Gdy pojawia się konto użytkownika, potrzebujesz odpowiedzi na dwa pytania: kim jest osoba, która wysyła żądanie, i czy może to zrobić. Poniżej budujesz pierwszą część od zera: logowanie, token i ochronę endpointów, a potem wywołujesz je z JavaScriptu. Jeśli nie znasz jeszcze podstaw, zacznij od pierwszego API w FastAPI.

Czym różni się JWT od OAuth2?

To dwa pojęcia z różnych warstw i najczęściej są mylone:

JWTOAuth2
Co to jestformat tokenu: trzy części (nagłówek, payload, podpis) zapisane w Base64protokół opisujący, jak zdobyć token (przepływy, role, zakresy)
Odpowiada na pytanie„co jest w tokenie i czy ktoś go nie zmienił?”„jak klient dostaje token i od kogo?”
PrzykładeyJhbGciOi...logowanie loginem i hasłem do własnego API albo „Zaloguj przez Google”
Czy da się bez drugiegotak, token JWT można wystawić bez OAuth2tak, OAuth2 może wystawiać tokeny w innym formacie

W FastAPI nazwa OAuth2PasswordBearer oznacza konkretną rzecz: „klient wysyła login i hasło na jeden adres (/token), dostaje token i potem dołącza go do żądań w nagłówku Authorization: Bearer ...”. Klasa nie wystawia i nie sprawdza tokenu sama, tylko wyciąga go z nagłówka i podpowiada dokumentacji /docs, gdzie jest logowanie. Zawartość tokenu (na przykład JWT) i jego sprawdzenie to Twój kod.

Dodatkowo rozróżnij uwierzytelnianie (kim jesteś) od autoryzacji (co Ci wolno). Ten artykuł dotyczy pierwszego. „Zaloguj przez Google” to OAuth2 z zewnętrznym dostawcą, który wystawia token za Ciebie, i jest osobnym tematem.

Jak działa logowanie z JWT krok po kroku?

  1. Klient wysyła login i hasło na POST /token.
  2. Serwer sprawdza hasło z bazą (porównuje hashe), a przy sukcesie wystawia token podpisany tajnym kluczem i ważny kilka minut.
  3. Klient dołącza token do każdego kolejnego żądania: Authorization: Bearer <token>.
  4. Endpoint chroniony sprawdza podpis i datę ważności. Jeśli się zgadzają, wie, kim jest użytkownik, bez zaglądania do bazy po hasło.

Dzięki podpisowi nikt nie może samodzielnie zmienić zawartości tokenu, a krótki czas ważności ogranicza szkody po jego kradzieży.

Jakie pakiety zainstalować?

pip install fastapi uvicorn python-multipart pyjwt "pwdlib[argon2]"
  • python-multipart jest potrzebny, bo formularz logowania wysyła dane jako form-data (ten sam pakiet, który odbiera pliki, opisuję w artykule o uploadzie plików w FastAPI).
  • pyjwt tworzy i sprawdza tokeny JWT.
  • pwdlib z Argon2 hashuje hasła.

Sekret do podpisywania tokenów wygeneruj raz i trzymaj poza kodem, w zmiennej środowiskowej:

export SECRET_KEY=$(openssl rand -hex 32)

Jak przechowywać hasła?

Nigdy w postaci jawnej. W bazie zapisujesz wynik funkcji skrótu, której nie da się odwrócić, a przy logowaniu porównujesz skrót wpisanego hasła z zapisanym. Do tego służą specjalne algorytmy (Argon2, bcrypt), które celowo działają wolno, żeby odgadywanie haseł było kosztowne. Biblioteka pwdlib robi to jedną linijką i sama dobiera sól i parametry. Hash wygląda na przykład tak: $argon2id$v=19$m=65536,t=3,p=4$....

Jak zbudować logowanie i wystawić token?

import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash

SECRET_KEY = os.environ["SECRET_KEY"]
ALGORITHM = "HS256"
ACCESS_TOKEN_MINUTES = 15

password_hash = PasswordHash.recommended()

# w prawdziwej aplikacji użytkownicy są w bazie danych
users = {
    "anna": {
        "username": "anna",
        "password_hash": password_hash.hash("tajne-haslo-123"),
    }
}

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()


def create_access_token(username: str) -> str:
    expires = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_MINUTES)
    payload = {"sub": username, "exp": expires}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/token")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = users.get(form.username)
    if user is None or not password_hash.verify(form.password, user["password_hash"]):
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Nieprawidłowy login lub hasło",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return {"access_token": create_access_token(user["username"]), "token_type": "bearer"}

Kilka rzeczy, na które warto zwrócić uwagę:

  • Pola sub (kto) i exp (do kiedy) są standardowymi nazwami w JWT. Biblioteka sama pilnuje daty ważności.
  • Komunikat „Nieprawidłowy login lub hasło” jest wspólny dla obu przypadków. Nie zdradzasz, czy konto istnieje.
  • Kod 401 z nagłówkiem WWW-Authenticate: Bearer to poprawna odpowiedź na brak lub błąd uwierzytelnienia. Kod 403 zostaw dla sytuacji „wiem, kim jesteś, ale nie wolno Ci”.

Jak zabezpieczyć endpoint?

Ochrona to jedna zależność, którą dopisujesz do wybranych tras:

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except InvalidTokenError:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Token jest nieprawidłowy lub wygasł",
            headers={"WWW-Authenticate": "Bearer"},
        )
    user = users.get(payload.get("sub"))
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nieznany użytkownik")
    return user


@app.get("/me")
async def me(user: Annotated[dict, Depends(get_current_user)]):
    return {"username": user["username"]}

Depends(oauth2_scheme) wyciąga token z nagłówka Authorization. Funkcja get_current_user go sprawdza i zwraca użytkownika, a każda trasa, która ją wymaga, jest chroniona. O tym, jak zwracać błędy HTTP z endpointów i obsługiwać je w jednym miejscu, piszę w artykule o obsłudze błędów w FastAPI i Pydantic. Zwróć uwagę na algorithms=[ALGORITHM]: jawna lista dozwolonych algorytmów jest zabezpieczeniem. Token z algorytmem none (bez podpisu) jest wtedy odrzucany.

Jak przetestować logowanie w /docs?

Uruchom uvicorn main:app --reload i otwórz /docs. Przy chronionych trasach pojawia się kłódka i przycisk Authorize. Wpisujesz w nim login i hasło, a Swagger UI wysyła je pod adres z tokenUrl, zapisuje token i dołącza go do kolejnych wywołań. Jeśli token się nie pojawia, najpierw sprawdź, czy tokenUrl wskazuje dokładnie na Twój endpoint logowania.

Co zwróci API, gdy coś pójdzie nie tak?

Przetestowałem cały kod na typowych błędach:

SytuacjaStatusOdpowiedź
Brak nagłówka Authorization401Not authenticated
Zły schemat (Basic ... zamiast Bearer ...)401Not authenticated
Złe hasło przy logowaniu401Nieprawidłowy login lub hasło
Token wygasły401Token jest nieprawidłowy lub wygasł
Token podpisany innym kluczem401jak wyżej
Podmieniony payload (sub: admin) z oryginalnym podpisem401jak wyżej
Token z algorytmem none401jak wyżej
Logowanie wysłane jako JSON zamiast formularza422Field required dla pola username

Ostatni wiersz to klasyczna pułapka. OAuth2PasswordRequestForm czyta dane z formularza (form-data), więc klient musi wysłać username i password jako formularz, a nie jako ciało JSON. Jeśli frontend wyśle JSON, dostaniesz 422 mimo poprawnego hasła.

Czy dane w tokenie JWT są tajne?

Nie. Podpis chroni token przed zmianą, ale nie przed odczytaniem. Payload to zwykły JSON zapisany w Base64 i można go odkodować bez żadnego klucza. W moim teście z tokenu dostałem od razu {"sub": "anna", "exp": ...}. Dlatego:

  • nie wpisuj do tokenu haseł, numerów dokumentów ani innych danych wrażliwych,
  • trzymaj w nim identyfikator użytkownika i czas ważności, a resztę danych pobieraj z bazy,
  • traktuj sam token jak hasło: kto go ma, działa w Twoim imieniu do końca jego ważności.

Jak wywołać chronione API z JavaScriptu?

Logowanie wysyłasz jako formularz. URLSearchParams ustawia potrzebny nagłówek Content-Type: application/x-www-form-urlencoded automatycznie. Potem token dołączasz do każdego żądania:

const API = 'http://localhost:8000';

async function login(username, password) {
  const response = await fetch(`${API}/token`, {
    method: 'POST',
    body: new URLSearchParams({ username, password }),
  });
  if (!response.ok) throw new Error(`Logowanie nieudane: ${response.status}`);
  const { access_token } = await response.json();
  return access_token;
}

async function apiFetch(path, token, options = {}) {
  const response = await fetch(`${API}${path}`, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  });
  if (response.status === 401) {
    // token wygasł lub jest zły: przekieruj do logowania
    return null;
  }
  return response.json();
}

const token = await login('anna', 'tajne-haslo-123');
console.log(await apiFetch('/me', token)); // { username: 'anna' }

Sprawdziłem ten kod w Node 22 względem uruchomionego uvicorn: logowanie zwraca token, /me odpowiada { username: 'anna' }, a zepsuty token kończy się statusem 401. Jeśli front działa na innym adresie niż API, przeglądarka wykona dodatkowe zapytanie wstępne (preflight) i zablokuje je bez poprawnych nagłówków CORS. Zobacz artykuł o CORS i błędach z nim związanych.

Gdzie przechowywać token w przeglądarce?

MiejsceZaletaRyzyko
Zmienna w pamięci JavaScriptuznika po odświeżeniu strony, trudniej go wykraśćużytkownik loguje się ponownie po każdym odświeżeniu
localStorageprzetrwa odświeżenie, najprostszekażdy skrypt na stronie (na przykład wstrzyknięty przez błąd XSS) może go odczytać
Cookie httpOnly ustawione przez serwerskrypt JavaScript go nie odczytawymaga ochrony przed CSRF i innej obsługi po stronie serwera

Nie ma jednej odpowiedzi dla każdej aplikacji. Na początek trzymaj token w pamięci albo w localStorage w aplikacji ćwiczeniowej, a w produkcyjnej rozważ cookie httpOnly.

Co z odświeżaniem tokenu (refresh token)?

Token ważny 15 minut jest bezpieczny, ale wylogowywałby użytkownika co kwadrans. Rozwiązaniem jest drugi token, refresh token, ważny dłużej (dni lub tygodnie). Gdy krótki token wygaśnie, klient wysyła refresh token na osobny endpoint i dostaje nowy. Ten drugi token powinien być przechowywany ostrożniej (najlepiej w cookie httpOnly), a serwer powinien umieć go unieważnić, na przykład przy wylogowaniu. Ten mechanizm wymaga zapisu w bazie, więc nie wchodzi w zakres tego artykułu.

Jakie błędy mentor poprawia najczęściej?

Uwaga mentora: najczęstszy błąd to potraktowanie JWT jak szyfru. Payload przeczyta każdy, kto ma token, więc nie wpisuj tam haseł ani danych, których nie pokazałbyś użytkownikowi. Trzymaj w tokenie identyfikator i czas ważności, resztę pobieraj z bazy.

Poniżej cała lista pułapek w jednym miejscu:

PułapkaSkutekJak uniknąć
Sekret w repozytoriumkażdy, kto go zna, wystawi sobie dowolny tokenzmienna środowiskowa, a po wycieku nowy sekret
Hasła zapisane jawnie lub zwykłym skrótemwyciek bazy ujawnia hasłaArgon2 lub bcrypt przez pwdlib
Token bez expkradzież daje dostęp bezterminowozawsze czas ważności, najlepiej minuty
jwt.decode bez algorithms=[...]otwiera drogę do tokenów bez podpisu lub z podmienionym algorytmemjawna lista algorytmów
Dane wrażliwe w payloadziekażdy odczyta je z tokenutylko identyfikator i czas ważności
Logowanie wysłane JSON-em422 mimo poprawnych danychformularz (URLSearchParams, data= w httpx)
Różne komunikaty dla „zły login” i „złe hasło”atakujący sprawdza, które konta istniejąjeden wspólny komunikat

Co dalej?

Masz logowanie, token i chronione trasy. Kolejne kroki to użytkownicy w prawdziwej bazie danych (na przykład SQLAlchemy), autoryzacja oparta na rolach (zależność, która sprawdza, czy użytkownik jest administratorem), refresh token i logowanie przez zewnętrznego dostawcę, takiego jak Google. Pamiętaj też, by zapytania o użytkowników budować z parametrami, o czym piszę w artykule o SQL injection. Jeśli budujesz endpoint przyjmujący pliki, zabezpiecz go tą samą zależnością, a sam upload opisuje artykuł o uploadzie plików w FastAPI. Jeśli chcesz uczyć się backendu w Pythonie z mentorem, który ocenia Twój kod, sprawdź kurs Python backend.

Konsultacja i warsztaty devmentor.pl

Masz pytania?