Skip to main content

Internal Service-to-Service Authentication

Status: Shipped in Phase 1 hardening (2026-04, branch feat/service-account-jwt). Foundation for the cross-service ServiceClient pattern.

When a backend service calls another backend service, it authenticates with a long-lived service-account JWT instead of forwarding a user token. This document describes the token format, the verification flow, and the operational lifecycle.


Why a Separate Token Type?​

Forwarding the originating user's JWT to downstream services has three problems:

  1. Token expiry mismatch. User access tokens are 15 minutes. A long-running cron job that started 14 minutes ago and now needs to read the property service will fail.
  2. Background jobs have no user. Night audit, scheduled rate publication, OTA polling, message-queue consumers — none of these have an inbound user request to source a token from.
  3. Unbounded blast radius. If a user token leaks during inter-service transit, the attacker gets that user's full permissions across the platform. A service-account token limits the blast to one service identity.

The fix is a separate JWT type, signed by the same key but with a distinct aud claim and narrower scopes.


Token Structure​

{
"sub": "service:hmp-reservation-service",
"scope": "service",
"aud": "hmp-internal",
"iat": 1712345678,
"exp": 1743881678,
"iss": "hmp-auth-service",
"tenantId": null,
"propertyId": null
}
ClaimMeaning
subThe calling service identity (service:<service-name>)
scopeAlways service for service-account tokens (vs user for end-user tokens)
audAlways hmp-internal — distinguishes from user tokens (aud: hmp-public)
iat / expStandard JWT timing claims. Service tokens are typically valid for 1 year
issAlways hmp-auth-service
tenantId / propertyIdAlways null in the token. Forwarded via headers per request

The token is HS256 today (planned migration to RS256 — see Follow-ups).


How JwtAuthGuard Distinguishes Token Types​

JwtAuthGuard (in @hmp/auth) accepts both kinds of tokens:

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const payload = await this.verifyToken(req);

if (payload.scope === 'service' && payload.aud === 'hmp-internal') {
// Service-account path: identity is the calling service
req.user = {
id: payload.sub,
type: 'service',
tenantId: req.headers['x-tenant-id'],
propertyId: req.headers['x-property-id'],
requestId: req.headers['x-request-id'],
};
return true;
}

// User path: identity is the end user, tenant/property come from token
req.user = {
id: payload.sub,
type: 'user',
tenantId: payload.tenantId,
propertyId: payload.propertyId,
roles: payload.roles,
};
return true;
}

The key insight: for service-account tokens, the header is the source of truth for tenant and property scope. This means the receiving controller must still call assertPropertyTenant() to verify that the claimed property belongs to the claimed tenant. The token does not vouch for that linkage.


How BaseServiceClient Sends the Token​

export abstract class BaseServiceClient {
protected readonly axios: AxiosInstance;

constructor(baseURL: string) {
this.axios = axios.create({ baseURL, timeout: 10_000 });
this.axios.interceptors.request.use((config) => {
const ctx = AsyncContext.getStore();
config.headers.set(
'Authorization',
`Bearer ${process.env.INTERNAL_SERVICE_TOKEN}`,
);
if (ctx?.tenantId) config.headers.set('x-tenant-id', ctx.tenantId);
if (ctx?.propertyId) config.headers.set('x-property-id', ctx.propertyId);
if (ctx?.requestId) config.headers.set('x-request-id', ctx.requestId);
return config;
});
}
}

Subclasses just call this.axios.get(...) and inherit the auth + correlation behavior.


Token Generation and Rotation​

Generating a token​

cd hmp-infrastructure
./scripts/generate-service-token.sh \
--service hmp-reservation-service \
--ttl 365d \
--secret-from-env JWT_SECRET

The script reads the JWT signing secret from the environment (typically loaded from AWS Secrets Manager during deploys), signs an HS256 JWT with the structure above, and prints it to stdout.

Rotating tokens​

  1. Generate a new token with the same secret.
  2. Roll out the new value to all services as INTERNAL_SERVICE_TOKEN (one service at a time via Kubernetes secret update + rolling restart).
  3. The old token continues to work until expiry — there is no revocation list. Plan rotations so old tokens expire after the rollout completes.

For emergency revocation, rotate the JWT signing secret itself. This invalidates all tokens (user and service) and forces every service to redeploy with the new secret. Use only as a last resort.


Environment Variables​

VariableUsed ByPurpose
JWT_SECRETAll servicesHS256 signing key. Same value across all services
INTERNAL_SERVICE_TOKENAll servicesThe pre-signed service-account JWT this service sends on outbound calls
JWT_AUDIENCE_INTERNALAll servicesDefault hmp-internal; verified on inbound service tokens
JWT_AUDIENCE_PUBLICAll servicesDefault hmp-public; verified on inbound user tokens

In dev (docker-compose.demo.yml), all services share a single static service token defined in .env.demo. In staging and prod, each service gets its own token via External Secrets Operator → AWS Secrets Manager.


Security Considerations​

  1. Tenant + property forwarding via headers, never claims. The token does not pin the caller to a tenant. Receivers must enforce ownership server-side.
  2. Single aud value (hmp-internal) prevents service tokens from being accepted by any public-facing endpoint that requires hmp-public.
  3. No refresh flow. Service tokens are rotated by ops, not by the calling service. There is no /auth/refresh endpoint for them.
  4. Loggers must redact Authorization headers. The RedactInterceptor in @hmp/observability already handles this — confirm in any new logging path.
  5. Scope is single-valued today (service). Phase 2 will introduce per-service scopes (service:reservation, service:property) so tokens can be revoked per identity.

Follow-ups​

  • RS256 migration. Today HS256 means a leaked secret on any service can mint tokens for any other. RS256 with per-service public keys is the target before production launch. Tracked in project_phase2-phase3-scope.md.
  • Mutual TLS option. For payment-service in particular, mTLS would supplement the JWT layer. Out of scope for Phase 1.

  • cross-service-communication.md — the higher-level ServiceClient pattern this auth mechanism enables
  • multi-property-isolation.md — receiver-side tenant enforcement that complements service-account tokens
  • hmp-infrastructure/scripts/generate-service-token.sh — token generation script