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 wyciekuJakKto zazwyczajCzas do wykrycia
Publiczne repozytoriumCommit z .env lub hardcoded keyJunior developerMinuty (skanery) - nigdy
Confluence / Notion"Dokumentacja integracji"Tech leadMiesiące - lata
Slack / Teams"Hej, daj mi klucz do API"KażdyNigdy
Logi aplikacjiToken w URL lub headerze logowanymNikt (automatycznie)Miesiące
Laptop byłego pracownika.bash_history, Postman, IDEEx-employeeNigdy
CI/CD pipelineEnv var widoczny w logach buildaDevOpsTygodnie

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:

  1. Krótkie życie - sekundy do minut, nie miesiące do "nigdy"
  2. Wąski scope - uprawnienie do jednej operacji, nie do całego API
  3. 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:

CechaCSRF tokenJIT token
CelOchrona przed cross-site request forgeryAutoryzacja dostępu do zasobu/operacji
ScopePowiązany z sesją użytkownikaPowiązany z konkretną operacją
TTLCzas życia sesji (godziny)Sekundy do minut
Kto używaPrzeglądarka (automatycznie w formularzach)Serwis, API client, backend
Co chroniPrzed akcją w imieniu użytkownikaPrzed nieautoryzowanym dostępem do zasobu
Gdzie żyjeHTML form / meta tag / cookieHeader Authorization / URL param / body
GranulacjaJedna sesja = jeden tokenJedna operacja = jeden token
Po wyciekuAtakujący może wykonać akcję jako userAtakują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}
end

Phoenix.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
end

Token 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
end

Plug 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"
end

Flow 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

CechaPermanentny tokenJIT token
Czas życiaMiesiące - lata - nigdySekundy - minuty
ScopePełen dostęp do APIJedna operacja / zasób
Po wyciekuPełen dostęp do czasu odkryciaMinuty do wygaśnięcia
RotacjaRęczna, "kiedyś to zrobimy"Automatyczna, każde żądanie
Audit trail"Token X zrobił coś""User Y żądał tokenu do operacji Z"
UnieważnienieWymaga centralnej blacklistyNiepotrzebne - wygasa sam
Koszt implementacjiNiski (wygeneruj i zapomnij)Średni (token exchange flow)
Koszt incydentuBardzo wysokiNiski - 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

ElementWyciek permanentnego tokenuWyciek JIT tokenu
Okno ekspozycjiDni - miesiące (do odkrycia)Sekundy - minuty (TTL)
Dane narażoneWszystko w scope tokenu (zazwyczaj: wszystko)Jeden zasób / operacja
Koszty prawne (RODO)50 000 - 500 000 PLNMinimalne (ograniczony zakres)
Kara PUODODo 4% rocznego obrotuPrawdopodobnie brak (brak masowego wycieku)
Forensics30 000 - 100 000 PLN (pełny audit)5 000 - 15 000 PLN (wąski zakres)
Powiadomienie klientów20 000 - 100 000 PLN (wszyscy dotknięci)Zazwyczaj niepotrzebne
Churn klientów5-15% w pierwszym kwartale~0%
ReputacjaArtykuł w Zaufana Trzecia StronaWpis w wewnętrznym post-mortem
Razem200 000 - miliony PLN5 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ści
  • Plug.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.