backend

JWT (JSON Web Token): ce este, cum funcționează, cum îl folosești în siguranță

JWT este formatul standard pentru autentificare stateless. Cum se compune, ce trebuie să faci cu refresh tokens, și capcanele care îți strică securitatea.

Cuprins

JWT (JSON Web Token, RFC 7519) este formatul standard pentru transmiterea informațiilor de autentificare între un client și un server, semnat cryptografic ca să nu poată fi modificat. Conține trei părți codificate base64: header (cum este semnat), payload (cine este utilizatorul și ce claims are) și signature. Pentru API-uri stateless, JWT este alternativa la sesiunile clasice cu cookie + storage central.

Termenul a apărut în 2015 odată cu RFC 7519 și a devenit rapid standardul de facto pentru auth în SPA-uri și API-uri mobile. Mai puțin discutat este că JWT vine cu un set de capcane (semnătură none, alg confusion, invalidare dificilă) care au generat zeci de CVE-uri în primii ani. Implementarea sigură nu e dificilă, dar cere atenție.

Ce este JWT mai exact?

Un șir de caractere de forma xxxxx.yyyyy.zzzzz unde fiecare bucată este un fragment codificat base64url. Decodate, sunt:

  • Header: un obiect JSON care declară algoritmul de semnare (alg) și tipul (typ: "JWT"). Exemplu: {"alg":"HS512","typ":"JWT"}.
  • Payload: claims-urile, adică datele despre utilizator. Câmpuri standard: sub (subject/utilizator), iat (issued at), exp (expirare), iss (issuer). Plus orice câmp custom (rol, ID de tenant, scope-uri).
  • Signature: rezultatul HMAC sau semnătură asimetrică (RSA, ECDSA) peste header + payload, cu cheia secretă a serverului. Garantează că payload-ul nu a fost modificat.

Două puncte care strică majoritatea conversațiilor despre JWT:

  • Payload-ul nu este criptat, doar semnat. Oricine cu acces la token poate decoda payload-ul. Nu pune date sensibile (parole, PII detaliat) acolo; pune doar identificatori și roluri.
  • Tokenul valid nu poate fi „retras". Dacă l-ai emis cu exp de o oră, va fi valid o oră, indiferent ce schimbi în baza de date. Singurele căi de invalidare sunt expirare scurtă sau o listă explicită de revocare (blacklist).

Cum funcționează un flow complet?

Un flow standard cu access + refresh token are patru pași:

  • Login. Utilizatorul trimite email + parolă. Serverul verifică și emite două tokenuri: un access token scurt (15-30 minute) și un refresh token lung (7-30 zile). Access-ul e folosit la fiecare cerere; refresh-ul e folosit doar pentru a obține noi tokens.
  • Cereri autentificate. Browserul trimite access-ul în header-ul Authorization: Bearer <token>. Serverul verifică semnătura și expirarea fără cerere la baza de date. Asta e ce face JWT stateless.
  • Expirare access. Când access-ul expiră, serverul răspunde cu 401. Browserul detectează 401, trimite refresh-ul la /auth/refresh și primește o pereche nouă de tokens.
  • Logout. Browserul trimite refresh-ul la /auth/logout care îl marchează ca revocat în baza de date. Access-ul rămâne valid până expiră (max 15-30 minute fereastră de risc).

Refresh-ul este partea cu adevărat sensibilă. Două strategii cu securitate diferită:

  • Refresh static. Același refresh token e valid de mai multe ori, până la expirare. Simplu, dar dacă tokenul e furat, atacatorul are acces până la expirare fără să fie detectat.
  • Refresh rotation cu reuse detection. La fiecare refresh, primești un token nou și cel vechi e marcat ca folosit. Dacă vine o cerere cu un token folosit deja, asta înseamnă că cineva a furat fie tokenul vechi, fie cel nou; toate sesiunile familiei sunt invalidate imediat.

Care sunt capcanele de securitate frecvente?

  • Alg confusion / alg=none. Versiuni vechi de biblioteci JWT acceptau tokenuri cu alg: "none" (fără semnătură). Atacatorul putea schimba payload-ul fără să fie detectat. Asigură-te că biblioteca ta refuză explicit none și valdează doar algoritmul așteptat (nu „orice algoritm semnat").
  • Secret slab. Cheia HMAC trebuie să aibă minimum 256 de biți (32 bytes). Cheile mai scurte sunt vulnerabile la brute force; cheile copiate din tutoriale sunt cunoscute public. Generează cu openssl rand -base64 64 și ține-o într-un password manager, nu în git.
  • Storage greșit pe client. Refresh tokens în localStorage sunt expuse la XSS. Pentru aplicații cu superficii de XSS, folosește cookies HttpOnly + SameSite=Strict. Pentru aplicații cu un singur admin auto-trustat (cazul nostru), localStorage e acceptabil.
  • Lipsa de rotație și reuse detection pentru refresh. Tokenul static de 30 de zile e o invitație. Implementează rotație și detectare de reutilizare; codul e ~50 de linii și salvează compromiterea unui cont dintr-un atac.
  • Claims netrustate folosite la decizii. Tokenul conține role: "admin" dar nu verifici în baza de date dacă utilizatorul mai este admin. Dacă rolul cuiva se schimbă, tokenul existent rămâne cu rolul vechi. Pentru roluri stabile, e OK; pentru permisiuni care se schimbă des, verifică în DB.

Cum folosim JWT la crawlerra?

Backendul nostru Spring Boot (crawlerra-backend) folosește jjwt 0.12.6 cu HS512 (auto-upgradat de la HS256 pentru că folosim o cheie de 64 de bytes), token rotation cu reuse detection, single-session policy (login-ul revocă sesiunea anterioară), și no-OAuth (un singur admin, fără email verify sau password reset). Implementarea trăiește în src/main/java/ro/crawlerra/security/JwtService.java, AuthenticationService.java, și RefreshTokenRevocationService.java (ultimul cu @Transactional(REQUIRES_NEW) ca să garantăm revocarea chiar dacă tranzacția părinte eșuează).

Access token-urile durează 15 minute, refresh token-urile 7 zile. Cheia JWT (HS512, 64 bytes random) este în /etc/systemd/system/crawlerra-backend.service.d/override.conf pe host, root-only mode 600, mirrorată în Vaultwarden ca sursă de adevăr. Pentru detectarea reutilizării, refresh token-urile sunt stocate în Postgres cu un câmp parent_token_id; când vine o cerere cu un token folosit, marcăm toată familia ca revocată. Schema este în db/changelog/changes/008-create-refresh-token.xml, gestionată prin Liquibase.

Cum verifici că totul e configurat corect?

Trei verificări de bază. Cheia HMAC are minimum 256 biți: echo -n "$JWT_SECRET" | wc -c trebuie să întoarcă cel puțin 32. Dacă e mai puțin, browserul tău e expus la brute force chiar acum.

Tokenul tău refuză alg: "none": trimite manual un token cu header-ul modificat și fără semnătură către un endpoint protejat. Trebuie să primești 401. Dacă primești 200, biblioteca ta acceptă tokenuri nesemnate (vulnerabilitate critică).

Refresh rotation funcționează: folosește același refresh token de două ori la rând. A doua cerere trebuie să eșueze, iar toate sesiunile asociate trebuie să fie invalidate. Dacă a doua cerere reușește, nu ai rotație (sau nu ai reuse detection). Pe crawlerra, am acoperit acest scenariu cu integration test în AuthFlowIT1; orice regresie iese imediat la mvn verify. Pentru monitorizare la runtime, vezi alertele de auth-failure-rate în stack-ul de observabilitate; un spike înseamnă fie atac în desfășurare, fie deploy stricat (în ambele cazuri vrei să afli rapid). Asta e parte din practica generală de site reliability engineering care subordonează disponibilitatea unei discipline măsurabile. Despre principiul „răspundem propriilor noastre alerte", vezi articolul nostru de manifestare editorială.

  1. AuthFlowIT.java conține un test (loginRefreshLogoutFlow) care simulează reutilizarea refresh-ului și verifică invalidarea familiei. [crawlerra.auth_flow_it]

Întrebări frecvente

JWT vs session-based auth, care e mai bun?

JWT pentru API-uri stateless și multi-server, sesiuni clasice pentru aplicații web monolitice. JWT scalează orizontal fără storage central, dar invalidarea este greu de făcut sub 15 minute. Sesiunile au invalidare instant prin ștergerea rândului în baza de date, dar cer storage partajat între servere. Pentru un microserviciu API consumat de mobile + web, JWT câștigă; pentru un panou de admin clasic, sesiuni.

Cât trebuie să dureze un access token?

Scurt, între 5 și 30 de minute, niciodată o oră sau mai mult. Tokenul scurt limitează fereastra de exploatare dacă e furat. Pe crawlerra folosim 15 minute pentru access și 7 zile pentru refresh, cu refresh rotation: la fiecare refresh, primești un token nou și cel vechi e invalidat. Asta detectează reutilizarea (dacă același refresh token e folosit de două ori, ambele sesiuni sunt deconectate).

Pot folosi JWT pentru un API public?

Da, dar API keys sunt de obicei mai potrivite. JWT-urile sunt destinate utilizatorilor autentificați (cu refresh tokens, expirare, claims dinamice). Pentru consumatori de API care vor să integreze permanent, un API key static (rotabil) e mai simplu de gestionat. JWT pentru utilizatori, API keys pentru servicii.

Cum invalidez un JWT compromis?

Nu poți pe tokenul deja emis, dar poți invalida refresh token-ul. Asta e principala critică adusă JWT: tokenul de access semnat continuă să fie valid până expiră. Soluții practice: token scurt (5-15 min, limitezi fereastra), refresh rotation cu reuse detection (la suspiciune, invalidezi familia), sau un mic blacklist de jti-uri în Redis pentru cazuri extreme. Noi am ales prima + a doua, fără blacklist.

HS256, HS384, HS512, care algoritm alege?

HS256 e suficient pentru majoritatea cazurilor. Diferența de securitate practică între HS256 și HS512 e neglijabilă; cel din urmă produce semnături mai lungi (84 vs 43 bytes) și ocupă mai mult în header. Excepție: dacă cheia ta secretă este mai lungă de 64 de bytes, biblioteca poate să te urce automat la HS384 sau HS512 (jjwt face asta tăcut). Asigură-te că documentezi algoritmul efectiv folosit.