2025-12-10 | PreviewProof Team
Service-to-Service Authentication in Multi-Service Preview Environments
A single-service app previews cleanly. The moment your preview involves two services authenticating to each other — an API and a worker, a frontend and three microservices, a mobile backend and a payments service — the dynamic-URL problem returns uglier. It’s no longer about user-facing OAuth callbacks. It’s about how Service A proves to Service B that it is who it says it is, when both A and B are deployed at URLs that didn’t exist this morning.
Four patterns. Two are easy and weak. Two are stronger and require more setup. Pick based on what your services already do in production.
Pattern 1: Shared Static Credentials
The simplest thing that could work: every preview shares one set of credentials. A single JWT signing key, a single mTLS cert, a single API token that all services trust. Provisioned once, injected into every preview as a secret.
Fine for a small team building a small product. Also a real security tradeoff. Any compromise of one preview’s container compromises every preview’s auth, and the static-credentials story tends to leak into staging and (if you’re not careful) production. Treat it as “we’ll fix this later,” not “this is the answer.”
The tell that you’ve outgrown this pattern: a developer needs to test a permission boundary — “does Service A correctly fail to call Service B’s admin endpoint” — and discovers the test is meaningless because every service has the same all-powerful token.
Pattern 2: Per-Preview Service Account Generation
At provision time, generate fresh credentials for each service in each preview. Service A in PR #482 gets its own client ID/secret pair. Service B in PR #482 gets a different one. The preview’s identity provider (or a small bespoke service-account issuer) knows the two are paired and trusts their tokens for each other.
The mechanism varies by stack. With a JWT-based system:
// at provision timeconst previewId = 'pr-482';const services = ['api', 'worker', 'webhooks'];
for (const svc of services) { const keyPair = generateKeyPair(); await secretsManager.put(`preview/${previewId}/${svc}/private-key`, keyPair.private); await registry.register({ issuer: `preview-${previewId}-${svc}`, publicKey: keyPair.public, audience: `preview-${previewId}`, });}Each service signs its own outbound JWTs with its private key. Each service validates inbound JWTs against the registered public keys in the preview’s registry. The registry is itself per-preview — a small JSON file, a Kubernetes ConfigMap, or an entry in your secrets store.
The hard part is teardown. Per-preview credentials are useful only if they’re revoked when the preview tears down. Otherwise you accumulate orphaned credentials in your secrets manager, which is exactly the kind of audit finding that surprises people. Wire credential revocation into the same teardown hook that tears down the database — see environment variables for ephemeral previews for the broader pattern.
Pattern 3: Mesh-Based Identity
If you’re already running a service mesh — Istio, Linkerd, Consul Connect — you have a built-in answer. Mesh identity is per-pod, derived from Kubernetes service accounts, rotated automatically. Service A talks to http://service-b; the mesh handles auth and routing.
For previews, this works beautifully if each preview gets its own mesh boundary: a dedicated namespace per preview, authorization policies scoped to that namespace, cross-namespace traffic blocked by default.
apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata: name: allow-preview-internal namespace: preview-pr-482spec: rules: - from: - source: namespaces: ["preview-pr-482"]The mesh handles JWT issuance, mTLS rotation, identity propagation. The cost is that everyone working on previews needs to understand enough mesh to debug them.
For Docker Compose-based previews, the equivalent is harder — Compose doesn’t have native identity per service. Don’t try to retrofit Istio onto Compose; do Pattern 2 instead.
Pattern 4: Auth Proxy with Preview-Aware Identity Injection
A small purpose-built proxy sits in front of each service. It validates incoming requests, mints internal tokens scoped to the preview, and rewrites headers before forwarding. The services themselves only see clean, validated, preview-scoped identities.
The proxy knows which preview it’s in, which other services exist in this preview (from a service registry), and how to mint tokens those services will accept. Downstream services trust whatever the local proxy tells them.
Functionally similar to a mesh but lighter weight, and works where you can’t run a real mesh — Compose, Nomad, plain ECS, Lambda. Often a 200-line Go service.
The Three Substrates
Kubernetes is the most mesh-friendly. Pattern 3 if you’re already running a mesh; Pattern 2 if you aren’t. Per-preview namespaces with NetworkPolicies are the standard isolation primitive.
Docker Compose has no native identity. Pattern 1 for prototypes, Pattern 2 for anything serious, Pattern 4 if you want centralization without a mesh.
Serverless (Lambda, Cloud Run) has its own identity primitives. AWS IAM roles per Lambda and per environment scope cleanly per-preview; GCP service accounts per Cloud Run do the same. Let the platform handle it. The trap: one Lambda calling another via the Lambda API bypasses the IAM checks you thought were happening — go through an HTTP boundary.
Observability: The Part Most Teams Skip
The biggest pain with cross-service auth in previews is debugging when it doesn’t work. The error a developer sees is “Service A returned 401” with no context. The cause could be a misissued token, clock skew, a stale public key, a misrouted request, or config drift between A and B.
Bake structured auth logging in from the start. Every auth decision should log the preview ID, the caller’s claimed identity, the validation result, and a correlation ID:
log.info({ event: 'auth.validate', preview_id: env.PREVIEW_ID, caller_iss: claims?.iss, result: 'audience_mismatch', expected_aud: env.SERVICE_AUDIENCE, actual_aud: claims?.aud, request_id: req.id,});Half the cross-service auth bugs in previews turn out to be “audience mismatch because the env var was set to the wrong preview’s audience.” With this logging, you find it in 30 seconds. Without it, 30 minutes attaching a debugger to two containers.
Propagate the correlation ID through your webhook router so you can trace a single user action across every service.
Picking a Pattern
Prototyping with one or two services: Pattern 1, with a runbook note that it’s temporary.
Three to ten services, not on a mesh: Pattern 2, with credential revocation wired into teardown.
Already running Istio or Linkerd: Pattern 3, scoped per preview namespace.
Polyglot mix on Compose, Nomad, or serverless: Pattern 4 with a small auth proxy.
The wrong move is to skip the question and let services trust each other based on network reachability. That works in previews until one of those services accidentally gets exposed publicly.
If you’d rather not build a per-preview identity registry, credential rotation, and structured auth observability yourself, PreviewProof handles cross-service identity for multi-service previews — credentials issued at provision, revoked at teardown, with auth events logged into the same evidence trail as the rest of the preview. If you’re going to build it yourself, the patterns above are the ones we’d recommend regardless of what platform you’re on.