JIT tokens - dlaczego Twoje API tokeny powinny żyć krócej niż kawa w biurze
Piątek, 21:37. Marta dostaje alert na Slack: "Anomaly detected - API rate limit exceeded on /api/v2/customers". Otwiera dashboard. Ktoś scrapuje bazę klientów. 200 requestów na sekundę, od trzech godzin. Token? Klucz API stworzony 14 miesięcy temu przez Bartka. Bartek odszedł z firmy 8 miesięcy temu. Klucz żyje w Confluence na stronie "API klucze - NIE USUWAĆ".
340 000 rekordów. Imiona, nazwiska, adresy, numery telefonów. Na zewnątrz.
Marta dzwoni do CTO. CTO dzwoni do prawnika. Prawnik mówi "RODO". Poniedziałek zaczyna się od maila do PUODO.
Token zrobił dokładnie to, do czego został stworzony. Problem w tym, że nikt nie powiedział mu, kiedy przestać.
Permanentne tokeny - tykająca bomba
Każda firma zaczyna tak samo. Pierwszy klucz API powstaje "bo integracja z Zapier potrzebuje". Drugi - "bo frontend team potrzebuje na staging". Trzeci - "bo skrypt do raportów". Po roku masz 47 aktywnych tokenów, z których 30 stworzyli ludzie, którzy już nie pracują w firmie.
Nikt ich nie trackuje. Nikt nie wie, który jest do czego. Nikt nie rotuje. Każdy ma pełen dostęp do API, bo "będziemy ograniczać scope później" (później nigdy nie nadchodzi).
GitHub w 2023 roku przeskanował publiczne repozytoria i znalazł 12.8 miliona sekretów - klucze API, tokeny, hasła. Wklejone w kod, w .env.example, w README ("tu wstaw swój klucz"), w komentarzach ("// TODO: usunąć przed mergem").
Gdzie wyciekają permanentne tokeny
| Miejsce wycieku | Jak | Kto zazwyczaj | Czas do wykrycia |
|---|---|---|---|
| Publiczne repozytorium | Commit z .env lub hardcoded key | Junior developer | Minuty (skanery) - nigdy |
| Confluence / Notion | "Dokumentacja integracji" | Tech lead | Miesiące - lata |
| Slack / Teams | "Hej, daj mi klucz do API" | Każdy | Nigdy |
| Logi aplikacji | Token w URL lub headerze logowanym | Nikt (automatycznie) | Miesiące |
| Laptop byłego pracownika | .bash_history, Postman, IDE | Ex-employee | Nigdy |
| CI/CD pipeline | Env var widoczny w logach builda | DevOps | Tygodnie |
Permanentny token to master key, który nigdy nie wygasa, nigdy nie traci uprawnień i nigdy nie zapomina drogi do Twojego API. Jedyna obrona? Ktoś musi pamiętać, żeby go usunąć. Spoiler: nikt nie pamięta.
Czym są tokeny JIT
JIT - Just-In-Time. Token generowany w momencie użycia, z minimalnym czasem życia i wąskim zakresem uprawnień. Nie istnieje, dopóki nie jest potrzebny. Przestaje istnieć, gdy spełni swoje zadanie.
Trzy właściwości:
- Krótkie życie - sekundy do minut, nie miesiące do "nigdy"
- Wąski scope - uprawnienie do jednej operacji, nie do całego API
- Jednorazowość - użyty raz, unieważniony natychmiast
Analogia? Karta hotelowa vs master key. Karta hotelowa otwiera jeden pokój, przez określony czas, i przestaje działać po wymeldowaniu. Master key otwiera wszystko, zawsze, i jeśli go zgubisz - musisz wymienić zamki w całym hotelu.
Permanentny API token to master key. JIT token to karta hotelowa.
JIT vs CSRF - kuzyni, nie bliźniaki
Jeśli piszesz aplikacje webowe, tokeny JIT brzmią znajomo. Bo CSRF tokens - te losowe ciągi, które Phoenix wstawia do każdego formularza - działają na tej samej zasadzie.
Wspólne DNA:
- Efemeryczność - generowane per sesja/żądanie, nie na stałe
- Server-side weryfikacja - serwer generuje i serwer weryfikuje, klient nie może sfałszować
- Powiązanie z kontekstem - CSRF z sesją użytkownika, JIT z konkretną operacją
- Jednorazowość - po użyciu tracą wartość
Ale cel jest fundamentalnie inny:
| Cecha | CSRF token | JIT token |
|---|---|---|
| Cel | Ochrona przed cross-site request forgery | Autoryzacja dostępu do zasobu/operacji |
| Scope | Powiązany z sesją użytkownika | Powiązany z konkretną operacją |
| TTL | Czas życia sesji (godziny) | Sekundy do minut |
| Kto używa | Przeglądarka (automatycznie w formularzach) | Serwis, API client, backend |
| Co chroni | Przed akcją w imieniu użytkownika | Przed nieautoryzowanym dostępem do zasobu |
| Gdzie żyje | HTML form / meta tag / cookie | Header Authorization / URL param / body |
| Granulacja | Jedna sesja = jeden token | Jedna operacja = jeden token |
| Po wycieku | Atakujący może wykonać akcję jako user | Atakujący ma sekundy zanim token wygaśnie |
CSRF tokens nauczyły nas, że efemeryczne tokeny działają. Że nie trzeba przechowywać stałych sekretów, żeby weryfikować uprawnienia. JIT tokens biorą tę zasadę i stosują ją do autoryzacji API - tam, gdzie do tej pory królowały permanentne klucze.
Gdzie już używasz JIT tokenów (i nie wiesz o tym)
JIT tokens to nie nowy koncept. Używasz ich codziennie:
- OAuth2 access tokens - żyją 15 minut do 1 godziny. Refresh token generuje nowy. To jest JIT
- Magic links - Phoenix 1.8 generuje jednorazowy link do logowania. Klikasz, logujesz się, link umiera. To jest JIT
- Pre-signed URLs (S3) -
GET /bucket/file?X-Amz-Expires=300. URL działa 5 minut, potem jest bezwartościowy. To jest JIT - TOTP / 2FA - kod z Google Authenticator zmienia się co 30 sekund. Użyty raz, nie działa ponownie. To jest JIT
- WebSocket tickets - Phoenix generuje token do połączenia WebSocket, ważny kilka sekund. To jest JIT
Zasada jest wszędzie. Tylko w API - tam gdzie ryzyko jest największe - wciąż dominują permanentne klucze.
Implementacja w Phoenix
Phoenix.Token - gotowe narzędzie
Phoenix ma wbudowany moduł do podpisywanych, krótkotrwałych tokenów. Zero zależności, zero konfiguracji:
# Generowanie tokenu -- np. w kontrolerze po autoryzacji
token = Phoenix.Token.sign(
MyAppWeb.Endpoint,
"download", # sól -- różna per typ operacji
%{user_id: user.id, file_id: file.id}
)
# Token: "SFMyNTY.g2gDaAJhA2..."
# Wygląda jak bełkot. Ale w środku: payload + podpis HMAC + timestamp.
# Weryfikacja -- max 5 minut od wygenerowania
case Phoenix.Token.verify(MyAppWeb.Endpoint, "download", token, max_age: 300) do
{:ok, %{user_id: user_id, file_id: file_id}} ->
# Token ważny, payload zweryfikowany
serve_file(user_id, file_id)
{:error, :expired} ->
# Token wygasł -- za stary
{:error, :token_expired}
{:error, :invalid} ->
# Token sfałszowany lub z inną solą
{:error, :invalid_token}
endPhoenix.Token daje Ci JIT token w dwóch linijkach. Podpis kryptograficzny (secret_key_base), timestamp, weryfikacja TTL - wszystko wbudowane. Nie potrzebujesz bazy danych, nie potrzebujesz Redisa, nie potrzebujesz JWT.
JIT token do jednorazowego pobrania
Scenariusz: użytkownik klika "Pobierz raport". System generuje token ważny 5 minut, który działa dokładnie raz. Drugi klik - nowy token.
defmodule MyAppWeb.DownloadController do
use MyAppWeb, :controller
@token_ttl 300 # 5 minut
# Krok 1: Użytkownik żąda pobrania → generujemy jednorazowy token
def request_download(conn, %{"file_id" => file_id}) do
user = conn.assigns.current_user
nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64()
# Zapisz nonce w ETS -- token jest ważny tylko jeśli nonce istnieje
:ets.insert(:download_tokens, {nonce, false})
token = Phoenix.Token.sign(
MyAppWeb.Endpoint,
"file_download",
%{user_id: user.id, file_id: file_id, nonce: nonce}
)
json(conn, %{download_url: "/api/download?token=#{token}"})
end
# Krok 2: Użytkownik pobiera plik → weryfikujemy i unieważniamy token
def download(conn, %{"token" => token}) do
with {:ok, payload} <- Phoenix.Token.verify(
MyAppWeb.Endpoint, "file_download", token, max_age: @token_ttl
),
# Sprawdź czy nonce istnieje i nie był użyty
[{_nonce, false}] <- :ets.lookup(:download_tokens, payload.nonce),
# Atomowo oznacz jako użyty (zwraca true jeśli zamiana się udała)
true <- :ets.update_element(:download_tokens, payload.nonce, {2, true}) do
# Cleanup nonce po użyciu
:ets.delete(:download_tokens, payload.nonce)
conn
|> put_resp_content_type("application/octet-stream")
|> send_download({:file, get_file_path(payload.file_id)})
else
_ ->
conn |> put_status(401) |> json(%{error: "Token invalid or already used"})
end
end
endToken jest chroniony podwójnie: kryptograficznie (podpis + TTL) i logicznie (jednorazowy nonce w ETS). Nawet jeśli ktoś przechwyci URL - ma 5 minut i jedno użycie. Po tym token jest martwą literą.
Token exchange - serwis → serwis
W architekturze mikroserwisowej serwis A musi wywołać serwis B. Zamiast permanentnego klucza API - wymiana credentials na krótkotrwały token:
defmodule MyApp.TokenExchange do
@token_ttl 60 # 60 sekund -- wystarczy na jeden request + retry
# Serwis żądający dostępu -- wymienia credentials na JIT token
def request_token(service_id, service_secret, scope) do
with :ok <- verify_credentials(service_id, service_secret),
:ok <- verify_scope(service_id, scope) do
token = Phoenix.Token.sign(
MyAppWeb.Endpoint,
"service_token",
%{service_id: service_id, scope: scope, issued_at: System.system_time(:second)}
)
{:ok, %{token: token, expires_in: @token_ttl, scope: scope}}
else
_ -> {:error, :unauthorized}
end
end
# Weryfikacja przychodzącego tokenu
def verify_token(token, required_scope) do
case Phoenix.Token.verify(MyAppWeb.Endpoint, "service_token", token, max_age: @token_ttl) do
{:ok, %{scope: scope}} when scope == required_scope -> :ok
{:ok, _} -> {:error, :insufficient_scope}
{:error, reason} -> {:error, reason}
end
end
defp verify_credentials(service_id, secret) do
# Weryfikacja credentials serwisu -- z bazy lub konfiguracji
expected = Application.get_env(:my_app, :service_credentials)[service_id]
if Plug.Crypto.secure_compare(expected, secret), do: :ok, else: :error
end
defp verify_scope(service_id, scope) do
allowed = Application.get_env(:my_app, :service_scopes)[service_id] || []
if scope in allowed, do: :ok, else: :error
end
endPlug do ochrony endpointów:
defmodule MyAppWeb.Plugs.ServiceAuth do
import Plug.Conn
def init(opts), do: opts
def call(conn, required_scope: scope) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
:ok <- MyApp.TokenExchange.verify_token(token, scope) do
conn
else
_ ->
conn
|> put_status(401)
|> Phoenix.Controller.json(%{error: "Invalid or expired service token"})
|> halt()
end
end
end# W routerze
pipeline :service_api do
plug MyAppWeb.Plugs.ServiceAuth, required_scope: "read:customers"
endFlow wygląda tak:
Serwis A Token Exchange Serwis B
│ │ │
├── POST /token ──────────────────►│ │
│ {service_id, secret, scope} │ │
│ │ │
│◄── {token, expires_in: 60} ──────┤ │
│ │ │
├── GET /api/customers ────────────┼──────────────────────────────►│
│ Authorization: Bearer <token> │ │
│ │ verify(token, scope)
│◄── 200 OK ───────────────────────┼───────────────────────────────┤
│ │ │
│ Token wygasa po 60s │ │
│ Następne żądanie → nowy token │ │Serwis A nigdy nie ma permanentnego dostępu do serwisu B. Ma 60-sekundowe okno na konkretną operację. Wyciek tokenu? Atakujący ma minutę zanim token stanie się bezwartościowy.
Permanentne vs JIT - porównanie
| Cecha | Permanentny token | JIT token |
|---|---|---|
| Czas życia | Miesiące - lata - nigdy | Sekundy - minuty |
| Scope | Pełen dostęp do API | Jedna operacja / zasób |
| Po wycieku | Pełen dostęp do czasu odkrycia | Minuty do wygaśnięcia |
| Rotacja | Ręczna, "kiedyś to zrobimy" | Automatyczna, każde żądanie |
| Audit trail | "Token X zrobił coś" | "User Y żądał tokenu do operacji Z" |
| Unieważnienie | Wymaga centralnej blacklisty | Niepotrzebne - wygasa sam |
| Koszt implementacji | Niski (wygeneruj i zapomnij) | Średni (token exchange flow) |
| Koszt incydentu | Bardzo wysoki | Niski - blast radius ograniczony |
Od permanentnego tokena do JIT w 4 krokach
Krok 1: Audit
Znajdź wszystkie aktywne tokeny. Wszystkie. Baza, Vault, .env, Confluence, Slack, CI/CD, Postman collections, lokalne maszyny devów. Zrób listę: kto stworzył, kiedy, do czego, czy osoba jeszcze pracuje w firmie.
Spoiler: będzie ich więcej niż myślisz.
Krok 2: Classify
Nie wszystkie tokeny nadają się do zamiany na JIT. Podziel je na kategorie:
Jaki to token?
│
┌──────────┼──────────┐
▼ ▼ ▼
Browser Serwis → Webhook /
/ User Serwis Callback
│ │ │
▼ ▼ ▼
OAuth2 + Token HMAC +
short- exchange timestamp
lived AT flow signature
│ │ │
▼ ▼ ▼
TTL: TTL: TTL:
15 min 60 sec per-request
verification
│ │ │
└──────────┼──────────┘
▼
┌──────────────┐
│ Jednorazowy │
│ download │
│ / export? │
└──────┬───────┘
│
▼
Signed URL +
nonce + TTL
(5 min, 1 use)Krok 3: Implement token exchange
Zacznij od serwis-do-serwis. Największe ryzyko, najmniejszy wpływ na UX. Wdróż TokenExchange jak w przykładzie wyżej. Monitoring: loguj każde wydanie tokenu z service_id, scope, timestamp.
Krok 4: Sunset
Wyłączaj permanentne tokeny stopniowo. Dual-mode: przez 30 dni akceptujesz oba typy. Loguj każde użycie permanentnego tokenu. Po 30 dniach - wyłączasz. Kto nie przeszedł na nowy flow - dostaje 401 i link do dokumentacji.
Koszt incydentu - liczby
| Element | Wyciek permanentnego tokenu | Wyciek JIT tokenu |
|---|---|---|
| Okno ekspozycji | Dni - miesiące (do odkrycia) | Sekundy - minuty (TTL) |
| Dane narażone | Wszystko w scope tokenu (zazwyczaj: wszystko) | Jeden zasób / operacja |
| Koszty prawne (RODO) | 50 000 - 500 000 PLN | Minimalne (ograniczony zakres) |
| Kara PUODO | Do 4% rocznego obrotu | Prawdopodobnie brak (brak masowego wycieku) |
| Forensics | 30 000 - 100 000 PLN (pełny audit) | 5 000 - 15 000 PLN (wąski zakres) |
| Powiadomienie klientów | 20 000 - 100 000 PLN (wszyscy dotknięci) | Zazwyczaj niepotrzebne |
| Churn klientów | 5-15% w pierwszym kwartale | ~0% |
| Reputacja | Artykuł w Zaufana Trzecia Strona | Wpis w wewnętrznym post-mortem |
| Razem | 200 000 - miliony PLN | 5 000 - 20 000 PLN |
Różnica nie jest procentowa. Jest rzędowa. Permanentny token, który wycieknie, to kryzys. JIT token, który wycieknie, to log entry.
Kiedy JIT tokeny NIE są odpowiedzią
Uczciwie - JIT tokens nie rozwiązują wszystkiego:
IoT / urządzenia edge - termometr w hali produkcyjnej nie może robić token exchange co 60 sekund. Nie ma kto odnowić tokenu, jeśli serwer autoryzacyjny jest niedostępny. Tu lepiej: certyfikaty klienckie (mTLS) z długim czasem ważności + allowlist.
Legacy integracje - partner wysyła Ci dane przez API, które napisali w 2018 roku w PHP. Nie przerobią swojego kodu na token exchange. Tu: permanentny token z wąskim scope, IP allowlist, monitoring anomalii + plan migracji.
CI/CD pipelines - GitHub Actions potrzebuje tokenu do deployu. Ale tu jest lepsze rozwiązanie: OIDC federation. GitHub Actions generuje JIT token automatycznie, bez sekretów w repo. GitLab i AWS mają analogiczne mechanizmy.
Złożoność operacyjna - startup z 3 developerami nie potrzebuje token exchange service. Koszt wdrożenia i utrzymania przewyższa ryzyko. Tu wystarczy: permanentne tokeny z krótkim max_age, automatyczna rotacja co 90 dni, monitoring użycia.
Latency-sensitive paths - jeśli dodatkowy roundtrip na token exchange przekracza Twój SLA - rozważ tokeny z dłuższym TTL (15 min zamiast 60s) lub cache'owanie tokenów w ETS do czasu wygaśnięcia.
Reguła: im wyższe ryzyko, tym krótszy token. Publiczne API z danymi klientów? JIT. Wewnętrzny endpoint do health checków? Permanentny token z wąskim scope wystarczy.
Phoenix już Ci pomaga
Elixir i Phoenix mają wbudowane mechanizmy, które implementują zasady JIT:
Phoenix.Token- podpisywane, krótkotrwałe tokeny z wbudowaną weryfikacją TTL. Gotowe do użycia, zero zależnościPlug.CSRFProtection- efemeryczne tokeny anti-forgery w każdym formularzu. JIT w czystej formie- Magic links (Phoenix 1.8) - jednorazowe linki do logowania. Token wygasa po użyciu lub po kilku minutach
- Signed cookies - sesje podpisane kryptograficznie z
secret_key_base. Nie do sfałszowania, nie do modyfikacji - BEAM distribution - komunikacja między nodami w klastrze przez
:erpc- zero tokenów. Nody uwierzytelniają się przez shared cookie przy połączeniu, potem komunikacja jest bezpośrednia. Żadnych sekretów w żądaniach
Ekosystem Elixira z natury preferuje efemeryczność. Procesy żyją krótko. Stan jest izolowany. Tokeny wygasają. To nie jest przypadek - to jest filozofia BEAM.
Masz w systemie klucze API, które żyją dłużej niż niektórzy pracownicy? Porozmawiajmy - pomożemy Ci przejść na tokeny JIT, zanim Twój "Bartek" zostawi za sobą master key do całego API.