← Back to the section

When order-service calls payment-service, who is it? If the answer is "well, it's inside the cluster" — that's not authentication, that's hope. Let's look at how services really prove their identity to one another.

Why the "internal network" doesn't protect you

A common misconception: since the services are in one Kubernetes cluster, they're already in a safe zone and you don't have to check who is calling whom.

The problem is that a single compromised pod opens a path to all its neighbors. If payment-service accepts requests from anyone who knows its URL, an attacker who got into the cluster through any breach gains access to the payment logic without any checks.

The zero trust principle sounds like this: every inter-service call is authenticated, even inside the cluster. Who are you — prove it, regardless of where you're calling from.

There are two proven ways to arrange this.

Way 1: mTLS via Istio

mTLS (mutual TLS) is when, at connection time, not only does the client verify the server's certificate, but the server also verifies the client's certificate. Each pod gets its own unique certificate, and by it you can tell exactly who is calling.

In a Kubernetes cluster with Istio (or Linkerd) this works automatically. Istio hands each pod a SPIFFE certificate, encrypts all internal traffic and verifies certificates on the receiver side. The NestJS application writes not a single line of authentication code — everything happens at the sidecar-proxy level before the request reaches the application.

// main.ts — the application starts as usual, mTLS is transparent to it
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Additionally, you can enable strict mTLS mode for a specific service through an Istio manifest:

# kubernetes/payment-service-peer-auth.yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: payment-service
  namespace: production
spec:
  selector:
    matchLabels:
      app: payment-service
  mtls:
    mode: STRICT

With STRICT the service will accept only mTLS connections — anonymous traffic is blocked at the network level.

Pros of mTLS: the service identity is built into the transport layer, certificates are renewed automatically every 24 hours, and the approach works the same for NestJS, Java, Python and Go.

There is one downside: you need a Service Mesh infrastructure. If there's no Istio, use the second way.

Way 2: Client Credentials Flow

This is the standard OAuth2 flow for machines. The client service requests a token from the identity provider (IdP), presenting its identifier and secret, and then attaches this token to every outgoing request.

The interaction scheme:

order-service → IdP: POST /oauth/token
                grant_type=client_credentials
                client_id=order-service-prod
                client_secret=$ORDER_SERVICE_CLIENT_SECRET
                scope=payment:charge

← IdP: { "access_token": "...", "expires_in": 3600 }

order-service → payment-service:
                POST /charge
                Authorization: Bearer <token>

payment-service: verifies the JWT signature and scope=payment:charge

Token caching

The token is issued for an hour (usually). Requesting a new one on every call is extra load on the IdP and extra milliseconds on every request. The right way: get the token once and keep it in memory, refreshing it ahead of expiry.

// adapters/out/payment/token-cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { AppConfig } from '../../../config/app.config';

interface TokenEntry {
  value: string;
  expiresAt: number;
}

@Injectable()
export class TokenCacheService {
  private readonly logger = new Logger(TokenCacheService.name);
  private cache = new Map<string, TokenEntry>();

  constructor(private readonly config: AppConfig) {}

  async token(scope: string): Promise<string> {
    const cached = this.cache.get(scope);
    // refresh 30 seconds before expiry to avoid a race
    if (cached && cached.expiresAt > Date.now() + 30_000) {
      return cached.value;
    }
    return this.fetch(scope);
  }

  private async fetch(scope: string): Promise<string> {
    const { data } = await axios.post<{ access_token: string; expires_in: number }>(
      this.config.auth.tokenUri,
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.config.auth.clientId,
        client_secret: this.config.auth.clientSecret,
        scope,
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
    );

    this.cache.set(scope, {
      value: data.access_token,
      expiresAt: Date.now() + data.expires_in * 1000,
    });
    this.logger.debug(`token refreshed for scope=${scope}`);
    return data.access_token;
  }
}

Notice: expiresAt > Date.now() + 30_000 — we refresh not when the token has already gone stale but 30 seconds before that. Clock skew between servers and network delays can lead to a token being formally still valid, but the IdP no longer accepting it.

A client that adds the token automatically

Instead of adding Authorization manually in every method, you set up an axios interceptor once when creating the client:

// adapters/out/payment/payment.client.ts
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import { AppConfig } from '../../../config/app.config';
import { TokenCacheService } from './token-cache.service';

@Injectable()
export class PaymentClient {
  private readonly http: AxiosInstance;

  constructor(
    private readonly tokens: TokenCacheService,
    config: AppConfig,
  ) {
    this.http = axios.create({ baseURL: config.payment.baseUrl });

    this.http.interceptors.request.use(async (req) => {
      const bearer = await this.tokens.token('payment:charge');
      req.headers['Authorization'] = `Bearer ${bearer}`;
      return req;
    });
  }

  async charge(orderId: string, amount: number): Promise<Receipt> {
    const { data } = await this.http.post<Receipt>('/charge', { orderId, amount });
    return data;
  }
}

The interceptor fires before every request and adds the current token. The charge method knows nothing about OAuth2 — it just makes an HTTP call.

A separate scope for each operation

The temptation to give a service one broad token for the entire interaction is a common mistake. If order-service is compromised and has a token with scope payment:*, the attacker can do anything with the payment service.

The right approach: the scope is tied to a specific operation.

// adapters/out/inventory/inventory.client.ts
async reserve(productId: string, qty: number): Promise<void> {
  const bearer = await this.tokens.token('inventory:reserve');
  await this.http.post('/reserve', { productId, qty }, {
    headers: { Authorization: `Bearer ${bearer}` },
  });
}

async release(reservationId: string): Promise<void> {
  const bearer = await this.tokens.token('inventory:release');
  await this.http.post(`/reservations/${reservationId}/release`, null, {
    headers: { Authorization: `Bearer ${bearer}` },
  });
}

order-service can reserve a product and release a reservation, but it cannot delete a product from the catalog (catalog:delete). Even if the service is compromised, the damage is limited to the operations it actually needs.

Anonymous traffic — a common mistake

Here is what a vulnerable client looks like:

// VULNERABLE: no Authorization
@Injectable()
export class CustomerClient {
  async profile(customerId: string): Promise<CustomerProfile> {
    const { data } = await axios.get(`${this.baseUrl}/customers/${customerId}`);
    return data;
  }
}

Any pod in the cluster can reach customer-service and get a customer's profile without any check.

The correct variant — the interceptor adds the token centrally:

// CORRECT: the interceptor adds Bearer to every request
@Injectable()
export class CustomerClient {
  private readonly http: AxiosInstance;

  constructor(tokens: TokenCacheService, config: AppConfig) {
    this.http = axios.create({ baseURL: config.customer.baseUrl });
    this.http.interceptors.request.use(async (req) => {
      req.headers['Authorization'] = `Bearer ${await tokens.token('customer:read')}`;
      return req;
    });
  }

  async profile(customerId: string): Promise<CustomerProfile> {
    const { data } = await this.http.get<CustomerProfile>(`/customers/${customerId}`);
    return data;
  }
}

Secrets — not in the code

clientSecret is the service's password. Keeping it in the code or in git is unacceptable. The rule is simple: only through environment variables or Vault.

// config/app.config.ts
import { IsString, IsUrl } from 'class-validator';

export class AuthConfig {
  @IsUrl()
  tokenUri: string;

  @IsString()
  clientId: string;

  @IsString()
  clientSecret: string;   // from process.env.CLIENT_SECRET, not a string in the code
}
# .env.example — examples only, real values via Vault / SealedSecrets
AUTH_TOKEN_URI=https://idp.internal/oauth/token
AUTH_CLIENT_ID=order-service-prod
AUTH_CLIENT_SECRET=<from-vault>

In short

  • The internal network does not guarantee security: a single compromised pod opens access to all its neighbors.
  • Two approaches: mTLS (via Istio/Linkerd, transparent to NestJS code) and the Client Credentials Flow (OAuth2 for environments without a Service Mesh).
  • With the Client Credentials Flow the token is cached in memory and refreshed 30 seconds before expiry — not manually by a timer, but on the next request.
  • The token is added through an axios interceptor once, not in every client method.
  • The scope is tied to a specific operation (payment:charge, inventory:reserve), not to the service as a whole.
  • clientSecret — only through environment variables or Vault, never in the code.