Kamil Owczarek
Opublikowano

Tokeny HMAC z timestampem: komunikacja zero-trust między twoimi własnymi serwisami

Autorzy

Problem z zaufaniem do samego siebie

Kiedy rozbijasz monolit na serwisy, coś, co było wywołaniem funkcji, staje się requestem HTTP. Wczoraj twój handler wyszukiwarki działał w tym samym procesie co sklep — uwierzytelnianie było niejawne. Dziś działa na osobnym serwerze, a w jego URL może uderzyć każdy z internetu.

Odruchowo sięga się po klucze API. Generujesz długi losowy string, wrzucasz go do zmiennych środowiskowych obu serwisów i sprawdzasz przy każdym requeście. Wystarczająco proste:

// Service B: check API key
if (request.headers['x-api-key'] !== process.env.API_KEY) {
  throw createError({ statusCode: 403, message: 'Forbidden' });
}

Działa, dopóki nie przestanie. Klucze API mają trzy cechy, które przy skali stają się obciążeniem:

Da się je odtworzyć. Jeśli ktoś przechwyci klucz API — przez wyciekły log, źle skonfigurowany endpoint debugowy, skompromitowaną zmienną w CI — może go używać w nieskończoność. Klucz nie wygasa. Klucz nie wie, kto go używa ani kiedy został przechwycony. Jest ważny, dopóki ręcznie nie zrotujesz go we wszystkich serwisach, które z niego korzystają.

Są statyczne. Rotacja klucza API oznacza jednoczesną aktualizację zmiennych środowiskowych we wszystkich serwisach. Przeoczysz jeden i komunikacja między serwisami sypie się na produkcji. W monorepo z trzema serwisami to trzy deploye, które muszą pójść równo. W większej architekturze to koszmar koordynacyjny.

Nic nie mówią o intencji. Klucz API mówi tyle: „wywołanie robi ktoś, kto ma ten string”. Nie mówi, kiedy request powstał, czy jest świeży ani czy nie został właśnie odtworzony z nagrania. Twój serwis przyjmie identyczne żądania o drugiej w nocy we wtorek i o trzeciej po południu w piątek, nie mając jak odróżnić prawdziwego ruchu od odtworzonej kopii.

Trafiliśmy na wszystkie trzy. Po rozbiciu monolitu na osobne serwisy potrzebowaliśmy sposobu, żeby serwis A mógł powiedzieć serwisowi B: „autoryzowałem ten konkretny request, właśnie teraz, a ta autoryzacja wygasa za kilka sekund”.

Wzorzec: HMAC plus timestamp

Rozwiązaniem jest podpisany token z timestampem. Zamiast przesyłać współdzielony sekret w każdym requeście, każdy serwis używa go do policzenia jednorazowej sygnatury nad krótko żyjącym znacznikiem wygaśnięcia.

Service A:
1. Pick expiration = now + 10 seconds
2. Compute HMAC-SHA256(secret, expiration) → token
3. Send request with ?_token=abc...&_expires=1773494437

Service B:
1. Check: is _expires in the future? (not expired)
2. Compute HMAC-SHA256(secret, _expires) → expected
3. Compare token === expected (constant-time)
4. If match → request is authentic and fresh

Zero zapytań do bazy. Zero stanu sesji. Zero przechowywania tokenów. Współdzielony sekret nigdy nie leci po kablu. Token jest matematycznie związany z konkretnym momentem w czasie i 10 sekund później jest już bezużyteczny.

Dlaczego to działa

Bezpieczeństwo tego wzorca opiera się na trzech własnościach HMAC (Hash-based Message Authentication Code):

Poprawną sygnaturę wyprodukuje tylko ktoś, kto zna sekret. HMAC-SHA256 bierze tajny klucz i wiadomość (u nas: znacznik wygaśnięcia) i zwraca 256-bitową sygnaturę. Bez sekretu nie podrobisz poprawnej sygnatury dla żadnego timestampa — nawet jeśli przechwyciłeś tysiące wcześniejszych, poprawnych sygnatur.

Sygnatura jest przywiązana do dokładnego wejścia. Zmiana jednego znaku w znaczniku wygaśnięcia daje kompletnie inną sygnaturę. Nie weźmiesz poprawnego tokenu dla _expires=1773494437 i nie użyjesz go z _expires=9999999999, żeby wydłużyć okno ważności.

Token jest z założenia ograniczony czasowo. Nawet jeśli atakujący wyłowi z logów sieciowych poprawną parę token-timestamp, po upływie ważności jest bezwartościowa. Dziesięciosekundowe okno oznacza, że ma 10 sekund na odtworzenie dokładnie tego samego requestu — a jeśli to zrobi, dostanie dokładnie tę samą odpowiedź, którą dostałby prawowity serwis. Nie ma tu ścieżki eskalacji.

Porównajmy to z kluczami API, tokenami JWT i przepływami OAuth:

PodejścieStanRotacjaOkno na replayZłożoność
Klucz APIBezstanoweRęcznaNieskończoneNiska
JWTBezstanoweRotacja certyfikatówMinuty do godzinŚrednia
OAuth 2.0Wymaga magazynu tokenówAutomatycznaKonfigurowalneWysoka
Token sesjiWymaga bazy danychPer sesjaDo unieważnieniaWysoka
HMAC plus timestampBezstanoweZmiana jednej zmiennej envSekundyNiska

Tokeny HMAC z timestampem trafiają w idealny punkt: prostota kluczy API z oknem na replay liczonym w sekundach zamiast w wieczności.

Implementacja: strona podpisująca

Serwis inicjujący request liczy sygnaturę i dokleja ją jako parametry query:

import { createHmac } from 'node:crypto';

export default defineEventHandler((event) => {
  const secret = process.env.SERVICES_SECRET;

  // Token expires 10 seconds from now
  const expires = (Math.floor(Date.now() / 1000) + 10).toString();

  // HMAC-SHA256: sign the expiration timestamp with the shared secret
  const token = createHmac('sha256', secret)
    .update(expires)
    .digest('hex');

  // Forward the original request with auth parameters
  const query = getQuery(event);
  const params = new URLSearchParams({
    ...query as Record<string, string>,
    _token: token,
    _expires: expires,
  }).toString();

  const targetUrl = `https://internal-service.example.com/endpoint?${params}`;
  return sendRedirect(event, targetUrl, 307);
});

Kluczowe szczegóły implementacji:

  • Sekundy, nie milisekundy. Math.floor(Date.now() / 1000) daje uniksowe sekundy. Precyzja milisekundowa jest zbędna, a utrudnia debugowanie.
  • Kodowanie hex. .digest('hex') daje string bezpieczny w adresie URL. Base64 wymagałoby escape'owania znaków +, / oraz =.
  • Parametry query, nie nagłówki. Przy przekierowaniach 307 (gdzie za przekierowaniem podąża przeglądarka) własne nagłówki są obcinane. Parametry query przeżywają przekierowanie.

Implementacja: strona weryfikująca

Serwis odbierający waliduje każdy request middlewarem, który odpala się przed jakimkolwiek handlerem trasy:

import { createHmac, timingSafeEqual } from 'node:crypto';

export default defineEventHandler((event) => {
  // Skip CORS preflight and health checks
  if (event.method === 'OPTIONS' || event.path === '/') return;

  const query = getQuery(event);
  const token = typeof query._token === 'string' ? query._token : undefined;
  const expires = typeof query._expires === 'string' ? query._expires : undefined;

  // Both parameters must be present
  if (!token || !expires) {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden' });
  }

  // Check expiration
  const now = Math.floor(Date.now() / 1000);
  const expiresNum = parseInt(expires);

  if (isNaN(expiresNum) || now > expiresNum) {
    throw createError({ statusCode: 403, statusMessage: 'Token expired' });
  }

  // Recompute the expected signature
  const expected = createHmac('sha256', process.env.SERVICES_SECRET)
    .update(expires)
    .digest('hex');

  // Constant-time comparison
  const tokenBuffer = Buffer.from(token);
  const expectedBuffer = Buffer.from(expected);

  if (
    tokenBuffer.length !== expectedBuffer.length ||
    !timingSafeEqual(tokenBuffer, expectedBuffer)
  ) {
    throw createError({ statusCode: 403, statusMessage: 'Invalid token' });
  }
});

W tej weryfikacji liczą się trzy rzeczy:

1. Najpierw sprawdź wygaśnięcie

Zawsze sprawdzaj timestamp, zanim policzysz HMAC. Jeśli token wygasł, nie ma powodu palić cykli CPU na operacje kryptograficzne. Dostajesz przy okazji czytelniejszy komunikat błędu: „Token expired” zamiast „Invalid token” mocno pomaga przy debugowaniu.

2. Porównanie w stałym czasie

To najczęściej pomijany szczegół. Naiwne porównanie stringów przez === przerywa się na pierwszym niepasującym znaku. Atakujący, który potrafi precyzyjnie mierzyć czasy odpowiedzi, mógłby odgadywać oczekiwany token znak po znaku (atak czasowy).

// ❌ WRONG: Timing attack vulnerable
if (token !== expected) {
  throw createError({ statusCode: 403 });
}

// ✅ CORRECT: Constant-time comparison
const tokenBuffer = Buffer.from(token);
const expectedBuffer = Buffer.from(expected);

if (
  tokenBuffer.length !== expectedBuffer.length ||
  !timingSafeEqual(tokenBuffer, expectedBuffer)
) {
  throw createError({ statusCode: 403 });
}

timingSafeEqual porównuje każdy bajt niezależnie od tego, czy różnica pojawiła się już na starcie. Porównanie trwa tyle samo, czy zgadza się zero bajtów, czy wszystkie. Jawne sprawdzenie długości jest konieczne, bo timingSafeEqual rzuca wyjątkiem, gdy bufory mają różną długość.

Czy to paranoja przy komunikacji między własnymi serwisami? Być może. Ale timingSafeEqual nic nie kosztuje wydajnościowo, a to dobry nawyk. W dniu, w którym użyjesz tego wzorca do czegoś bardziej wystawionego na świat, ochrona będzie już na miejscu.

3. Sprawdzenie długości chroni przed wywrotką

timingSafeEqual rzuca RangeError, gdy bufory mają różną długość. Bez sprawdzenia długości zniekształcony token wywróciłby twój middleware nieobsłużonym wyjątkiem, zamiast zwrócić czyste 403. To sprawdzenie jest jednocześnie środkiem bezpieczeństwa (blokuje wycieki czasowe oparte na długości) i środkiem niezawodnościowym (zapobiega wywrotkom).

Dlaczego 10 sekund?

Okno ważności to kompromis między bezpieczeństwem a niezawodnością.

Za krótko (1-2 sekundy): Rozjazd zegarów między serwerami sprawia, że prawidłowe requesty zaczynają się wywalać. Jeśli zegar serwisu A jest sekundę do przodu względem zegara serwisu B, dwusekundowy token ma efektywnie sekundę ważności. Opóźnienia sieciowe zjadają resztę okna. Zobaczysz sporadyczne 403, które trudno zdiagnozować.

Za długo (5 minut): Okno na replay zaczyna mieć znaczenie. Atakujący, który przechwyci poprawny token, ma 5 minut na jego odtworzenie. Przy endpointach wywołujących efekty uboczne (synchronizacja danych, unieważnianie cache) to może być groźne.

10 sekund to nasz idealny środek:

  • Mieści 1-2 sekundy rozjazdu zegarów między instancjami w chmurze
  • Zostawia ponad 8 sekund na opóźnienia sieciowe (aż nadto dla serwisów w tym samym regionie)
  • Jest na tyle krótkie, że atak replay zwraca tę samą odpowiedź, którą dostałby prawidłowy request — bez możliwości eskalacji
  • Jest na tyle długie, że prawidłowy request nigdy nie wywali się przez timing

Jeśli twoje serwisy rozciągają się na kilka regionów geograficznych (US East do EU West), rozważ 15-30 sekund, żeby uwzględnić większe opóźnienia. Jeśli siedzą w jednym regionie albo w jednej serwerowni, 5-10 sekund jest w zupełności komfortowe.

Co podpisywać: sam timestamp czy cały request

Nasza implementacja podpisuje wyłącznie znacznik wygaśnięcia. Możesz zapytać: czy nie powinniśmy podpisywać całego requestu (ścieżka, parametry query, body), żeby zapobiec manipulacji?

Przy komunikacji między serwisami za przekierowaniem podpisanie samego timestampa w zupełności wystarcza:

Signed timestamp:  "This request was authorized within the last 10 seconds"
Signed full request: "This exact request with these exact parameters was authorized"

Wybraliśmy sam timestamp, bo:

  1. Token podróżuje jako parametr query. Przy przekierowaniach 307 przeglądarka buduje pełny adres ze wszystkimi parametrami. Jeśli atakujący zmodyfikuje parametry, i tak potrzebuje poprawnego HMAC — którego bez sekretu nie wyprodukuje.
  2. Prostota. Podpisywanie całego requestu wymaga, żeby oba serwisy zgadzały się co do kanonicznego formatu requestu — kolejności parametrów, kodowania, wielkości liter. To klasyczne źródło subtelnych bugów. AWS Signature V4, który podpisuje cały request, ma na samą kanonikalizację wielostronicową specyfikację.
  3. Naszym modelem zagrożeń jest replay, nie manipulacja. Serwisy komunikują się po HTTPS, co i tak blokuje manipulację typu man-in-the-middle. HMAC chroni przed nieautoryzowanym dostępem i odtworzeniem, nie przed modyfikacją.

Jeśli twój model zagrożeń obejmuje manipulację parametrami (na przykład token przechodzi przez niezaufanego pośrednika), podpisz również istotne parametry:

// Sign timestamp + path + critical parameters
const message = `${expires}:${path}:${query.lang}`;
const token = createHmac('sha256', secret)
  .update(message)
  .digest('hex');

Ale przy większości wewnętrznej komunikacji serwis-serwis po TLS podpisywanie samego timestampa to właściwy kompromis złożoności.

Rotacja jest banalna

Rotacja współdzielonego sekretu to zmiana jednej zmiennej środowiskowej:

  1. Wygeneruj nowy sekret: openssl rand -hex 32
  2. Zaktualizuj zmienną środowiskową we wszystkich serwisach
  3. Zrób deploy

Nie ma bazy do zaktualizowania, magazynu tokenów do wyczyszczenia ani aktywnych sesji do unieważnienia. W momencie wdrożenia nowego sekretu stare tokeny (podpisane starym) przestają przechodzić walidację. A skoro tokeny i tak wygasają po 10 sekundach, nie ma żadnego „okresu przejściowego” do ogarnięcia.

Jeśli zależy ci na rotacji bez przestoju, możesz tymczasowo akceptować oba sekrety naraz:

const secrets = [process.env.SERVICES_SECRET, process.env.SERVICES_SECRET_OLD].filter(Boolean);

const isValid = secrets.some(secret => {
  const expected = createHmac('sha256', secret)
    .update(expires)
    .digest('hex');

  const tokenBuffer = Buffer.from(token);
  const expectedBuffer = Buffer.from(expected);

  return (
    tokenBuffer.length === expectedBuffer.length &&
    timingSafeEqual(tokenBuffer, expectedBuffer)
  );
});

Wdróż nowy sekret do wszystkich serwisów, ustawiając SERVICES_SECRET_OLD na poprzednią wartość. Kiedy wszystkie serwisy chodzą już na nowym kodzie, usuń starą zmienną. Łączny przestój podczas rotacji: zero.

CORS i ścieżka przekierowania w przeglądarce

Jeśli komunikacja między serwisami korzysta z przekierowań w przeglądarce (307 z API twojego frontendu do wewnętrznego serwisu), po stronie odbierającej potrzebujesz konfiguracji CORS:

export default defineEventHandler((event) => {
  const didHandleCors = handleCors(event, {
    origin: ['https://your-app.example.com', 'http://localhost:3000'],
    credentials: true,
    methods: '*',
    allowHeaders: ['Content-Type'],
  });

  if (didHandleCors) return; // Handled OPTIONS preflight
});

Kluczowy szczegół: przy credentials: true nagłówek Access-Control-Allow-Origin musi wskazywać konkretny origin, a nie *. Przeglądarki pilnują tego bezwzględnie — wildcard razem z credentials po prostu po cichu nie zadziała.

Uważnie ustaw kolejność middleware'ów:

0000-cors.middleware.tsHandle CORS preflight first
0001-security.middleware.tsLogging, rate limiting
0002-token-auth.middleware.tsHMAC validation (skips OPTIONS)

Middleware autoryzacji tokenu musi pomijać requesty OPTIONS — preflight CORS nigdy nie niesie twoich własnych parametrów query.

Kiedy ten wzorzec przestaje się sprawdzać

Tokeny HMAC z timestampem nie są uniwersalne. Oto kiedy sięgnąć po coś innego:

API wystawione dla klientów. Nie dasz użytkownikom końcowym swojego współdzielonego sekretu. Tokeny HMAC z timestampem są do komunikacji serwer-serwer, gdzie obie strony trzymają sekret. Do API dla klientów użyj OAuth, JWT albo kluczy API z porządnym rate limitingiem.

Serwisy multi-tenant. Jeśli serwis B obsługuje wielu wywołujących z różnymi uprawnieniami, jeden współdzielony sekret ich nie rozróżni. Potrzebowałbyś sekretu na każdego wywołującego, a wtedy budujesz już system zarządzania kluczami i prawdopodobnie powinieneś użyć JWT z claimami o określonym zakresie.

Wymogi audytowe. Tokeny HMAC są ulotne — po wygaśnięciu nie zostaje po nich ślad. Jeśli musisz audytować, kto co i kiedy wywołał, szczegóły requestów trzeba logować osobno. Sam token nie niesie żadnej informacji o tożsamości.

Problemy z synchronizacją zegarów. Jeśli twoje serwisy działają na sprzęcie z poważnym rozjazdem zegarów (ponad kilka sekund), okno ważności przestaje być wiarygodne. Dostawcy chmury zwykle trzymają zegary zsynchronizowane przez NTP z dokładnością do 1-2 milisekund, ale deploye na edge albo sprzęt on-premise potrafią odjechać mocniej. W takich przypadkach JWT z dłuższym oknem ważności i jawnym claimem iat (issued-at) będzie bardziej wyrozumiały.

Pełny przepływ

Tak wygląda cały cykl życia requestu:

1. Browser sends GET /api/search?q=faucet to your main app

2. Main app handler:
   - Computes HMAC-SHA256(secret, expires_timestamp) → token
   - Returns 307 redirect to:
     https://service.example.com/search?q=faucet&lang=en&_token=abc...&_expires=17734944

3. Browser follows redirect to service

4. Service middleware pipeline:
   a. CORS middleware → sets Access-Control-Allow-Origin, handles OPTIONS
   b. Security middleware → logs source IP
   c. Token auth middleware:
      - Parses _token and _expires from query
      - Checks: is _expires in the future?      - Computes HMAC-SHA256(secret, _expires) → expected
      - Compares: timingSafeEqual(token, expected)      - Request passes to route handler

5. Route handler executes (search, data processing, etc.)

6. Response flows directly from service to browser
   Main app uses zero memory — it only issued the redirect

Udział głównej aplikacji kończy się na kroku 2. Zjada około 1 ms na policzenie HMAC i zwrócenie 307. Serwis odbierający waliduje token w około 0,5 ms. Łączny narzut całego przepływu autoryzacji: poniżej 2 milisekund.

Najważniejsze wnioski

  1. Współdzielone sekrety nigdy nie powinny lecieć po kablu. HMAC pozwala obu stronom udowodnić znajomość sekretu bez jego przesyłania. Token wywodzi się z sekretu, ale sekretem nie jest.

  2. Tokeny ograniczone czasowo likwidują problem replay. Dziesięciosekundowe okno ważności sprawia, że przechwycone tokeny są bezwartościowe niemal natychmiast. Żadnych list unieważnień, magazynów sesji ani jobów sprzątających.

  3. Zawsze porównuj w stałym czasie. timingSafeEqual nic nie kosztuje, a chroni przed atakami czasowymi. Nie ma żadnego powodu, żeby z niego nie skorzystać.

  4. Podpisuj to, co ma znaczenie w twoim modelu zagrożeń. Przy wewnętrznych serwisach po TLS wystarczy podpisanie samego timestampa. Przy niezaufanych pośrednikach podpisz też kluczowe parametry requestu.

  5. Rotacja to zmiana zmiennej środowiskowej, nie migracja. Żadnych aktualizacji bazy, unieważniania tokenów ani koordynacji poza wdrożeniem nowego sekretu.

  6. Ten wzorzec świetnie łączy się z przekierowaniami 307. Główna aplikacja załatwia logikę uwierzytelniania i schodzi z drogi. Serwis odbierający waliduje token i wykonuje właściwą robotę. Żaden z nich nie dotyka budżetu pamięci drugiego.

Implementacja to jakieś 30 linii kodu po każdej stronie. Prymitywy kryptograficzne są wbudowane w Node.js — zero zewnętrznych zależności. Ten wzorzec od dekad działa w autentykacji tokenowej CDN-ów, weryfikacji webhooków i podpisywaniu w API gateway. Nie wymyślamy niczego nowego — po prostu przykładamy dobrze przetestowany wzorzec do typowego problemu mikroserwisów.

Czasem najlepszy system autentykacji to ten, w którym nie ma żadnego stanu do pilnowania.