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:
| JWT | OAuth2 | |
|---|---|---|
| Co to jest | format tokenu: trzy części (nagłówek, payload, podpis) zapisane w Base64 | protokół 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ład | eyJhbGciOi... | logowanie loginem i hasłem do własnego API albo „Zaloguj przez Google” |
| Czy da się bez drugiego | tak, token JWT można wystawić bez OAuth2 | tak, 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?
- Klient wysyła login i hasło na
POST /token. - Serwer sprawdza hasło z bazą (porównuje hashe), a przy sukcesie wystawia token podpisany tajnym kluczem i ważny kilka minut.
- Klient dołącza token do każdego kolejnego żądania:
Authorization: Bearer <token>. - 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-multipartjest potrzebny, bo formularz logowania wysyła dane jakoform-data(ten sam pakiet, który odbiera pliki, opisuję w artykule o uploadzie plików w FastAPI).pyjwttworzy i sprawdza tokeny JWT.pwdlibz 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) iexp(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: Bearerto 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:
| Sytuacja | Status | Odpowiedź |
|---|---|---|
Brak nagłówka Authorization | 401 | Not authenticated |
Zły schemat (Basic ... zamiast Bearer ...) | 401 | Not authenticated |
| Złe hasło przy logowaniu | 401 | Nieprawidłowy login lub hasło |
| Token wygasły | 401 | Token jest nieprawidłowy lub wygasł |
| Token podpisany innym kluczem | 401 | jak wyżej |
Podmieniony payload (sub: admin) z oryginalnym podpisem | 401 | jak wyżej |
Token z algorytmem none | 401 | jak wyżej |
| Logowanie wysłane jako JSON zamiast formularza | 422 | Field 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?
| Miejsce | Zaleta | Ryzyko |
|---|---|---|
| Zmienna w pamięci JavaScriptu | znika po odświeżeniu strony, trudniej go wykraść | użytkownik loguje się ponownie po każdym odświeżeniu |
localStorage | przetrwa odświeżenie, najprostsze | każdy skrypt na stronie (na przykład wstrzyknięty przez błąd XSS) może go odczytać |
Cookie httpOnly ustawione przez serwer | skrypt JavaScript go nie odczyta | wymaga 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łapka | Skutek | Jak uniknąć |
|---|---|---|
| Sekret w repozytorium | każdy, kto go zna, wystawi sobie dowolny token | zmienna środowiskowa, a po wycieku nowy sekret |
| Hasła zapisane jawnie lub zwykłym skrótem | wyciek bazy ujawnia hasła | Argon2 lub bcrypt przez pwdlib |
Token bez exp | kradzież daje dostęp bezterminowo | zawsze czas ważności, najlepiej minuty |
jwt.decode bez algorithms=[...] | otwiera drogę do tokenów bez podpisu lub z podmienionym algorytmem | jawna lista algorytmów |
| Dane wrażliwe w payloadzie | każdy odczyta je z tokenu | tylko identyfikator i czas ważności |
| Logowanie wysłane JSON-em | 422 mimo poprawnych danych | formularz (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.


