Rendered from this repo's docs/gateway/APP_INTEGRATION_GUIDE.md.
Audience: any Executive Talents engineer plugging their app into the gateway — either as a
provider (other apps/users reach your app through it), a consumer (your app calls other
apps through it), or both. Read CONTRACT.md for the authoritative wire contract;
this doc is the practical "what do I actually build" walkthrough. It's written to be stack-agnostic
— every code sample below is illustrative pseudocode-with-real-syntax for a specific language, not
a claim that this repo ships that language. Adapt the pattern (constant-time compare, headers in,
headers out) to whatever your app is actually written in.
Before writing any code, answer these for each route your app exposes:
As of ADR-GW-2 (CONTRACT.md §5), every route you declare lives at the same URL for every
kind of caller — /<your-slug>/<path>, namespaced, whether it's your own frontend's session
traffic or a granted external application. What used to be two different route classes with two
different public URLs is now one URL with a per-request auth decision:
scope empty (passthrough) |
scope set (machine-preferred) |
|
|---|---|---|
| Who it's for | Your own frontend / a browser session | Another registered application, with an explicit grant — but composes with human traffic on the same route, see below |
| Auth checked by the edge? | No — forwards Authorization unchanged |
Tries to verify a Bearer token as an EdDSA JWT, offline, via JWKS |
| No/invalid machine token presented | N/A — never attempted | Falls through to the same passthrough behavior as the left column — not rejected |
| Valid machine token, insufficient scope | N/A | Rejected outright (403 GW1003) — this is the one case that's still a hard reject |
| What your app receives | The original Authorization header, untouched |
X-Gateway-Client-Id / X-Gateway-Scopes only if verified; otherwise the same as the left column |
| Namespaced under your app's slug? | Yes — /<your-slug>/<path>, always |
Yes — /<your-slug>/<path>, always |
A route with scope set now automatically serves both a granted machine caller and an ordinary
human session at the same URL — the edge decides which, per request, by whether the presented
credential actually verifies. You don't need to hand-write a "compose both checks" guard for this
case anymore (see "Trust the gateway's forwarded identity" below) — just check whether
X-Gateway-Client-Id is present.
The one thing you can't express anymore: "this route must only ever be reachable by a
specific granted machine caller, reject anything else outright." A scope-bearing route always
has a passthrough fallback now. If you genuinely need that stronger guarantee for a specific
route, enforce it yourself downstream — reject if X-Gateway-Client-Id is absent — the same
"compose, don't replace" pattern below, just opted into rather than automatic.
GET /.well-known/gateway-manifest — a JSON document declaring your app's identity, scopes, and
routes (CONTRACT.md §2):
{
"appId": "your-app-slug",
"name": "Your App",
"description": "One line describing what this app does",
"version": "1.0.0",
"baseUrl": "https://your-app.internal",
"documentation": "https://your-app.internal/.well-known/openapi.json",
"owner": "Your Team",
"contact": "your-team@company.com",
"scopes": [
{ "key": "employee.read", "description": "Read employee records" }
],
"routes": [
{ "path": "/employees", "method": "GET", "scope": "employee.read" },
{ "path": "/status" }
]
}
appId is your slug — lowercase, alphanumeric-with-hyphens, unique across the gateway. This is
what your machine routes get namespaced under (/<appId>/<path>) and what other apps' grants
reference.baseUrl is what the edge actually forwards matched requests to — keep it as an address only the
gateway (and your own network) can reach, not a second public entry point (see "Network exposure"
below).routes[] entry with a non-empty scope is machine-class and must reference a key
declared in scopes[]; an entry with no scope is passthrough. method empty means "every
method on this path" — a genuine catch-all, not "whatever the first entry says."documentation, if you set it, must point at your own OpenAPI document (any shape your server
already returns — the control plane passes it straight through, it doesn't parse or validate it).
This is what makes GET /apps/{your-slug}/docs on the gateway serve your API docs through the
gateway's own domain, so a consumer never needs your real internal address just to read your API
shape (CONTRACT.md §11).(method, path) into a scope or passthrough
via an explicit rule table, with an "unclassified → warn and default to your most restrictive
scope" fallback so a newly added route never ships silently unclassified), or hand-write a
static file if your app is small enough that auto-discovery isn't worth building. Either is fine —
the gateway only cares about the resulting JSON shape, not how you produced it.Either way: mark this endpoint (and your health check) as unauthenticated/public in your own framework — a gateway can't fetch the manifest that tells it how to authenticate if the manifest endpoint itself requires authentication first.
Default assumption: anything a human calls today via your own frontend, unmodified, needs no
scope at all (leave scope empty) — or isn't registered with the gateway at all, if that traffic
never goes through the gateway. Only declare a scope once you have an actual reason another
application — not your own frontend — needs to call it on its own behalf, with its own granted
scope.
As of ADR-GW-2, getting this "backwards" (declaring a scope on a route your own frontend calls) is
no longer the integration-breaking mistake it used to be: the edge tries to verify the caller as a
machine token, fails (a session JWT doesn't verify as one), and falls through to the exact same
passthrough handling your frontend traffic would get anyway (CONTRACT.md §5.2). It costs one
wasted verification attempt per request, not a broken app. The reason to still get this right isn't
avoiding breakage — it's correctness of intent: a scope you declare is a real claim ("another
application can be granted this"), and an unused one is just noise in your manifest.
The edge already did the hard cryptographic work (EdDSA/JWKS verification) before forwarding a
verified machine request — and it only stamps X-Gateway-Client-Id/X-Gateway-Scopes when that
verification actually succeeded (CONTRACT.md §5.2/§7). Your app's job is simple: check for that
header's presence, and when it's there, verify the accompanying shared secret before trusting it.
X-Gateway-Client-Id: <the calling application's id> — present ONLY on a verified machine caller;
this is what you check for "is this machine"
X-Gateway-Scopes: <space-joined scopes the caller was actually granted>
X-Gateway-Auth: <GATEWAY_SHARED_HEADER_SECRET> — stamped on EVERY gateway-forwarded request,
both machine and passthrough — verify this
whenever X-Gateway-Client-Id is present, but
its presence alone does NOT mean "machine"
Do not key your check off X-Gateway-Auth presence — the edge stamps it on ordinary passthrough
traffic too (CONTRACT.md §7), so a check gated on "is X-Gateway-Auth present" would try to treat
every gateway-forwarded request as a machine call, including your own frontend's session traffic.
The header that's exclusive to a verified machine caller is X-Gateway-Client-Id.
Every stack follows the same shape: a piece of middleware that runs before your route handlers,
checks whether X-Gateway-Client-Id is present — if absent, it's a no-op, your normal session/Bearer
auth runs unmodified; if present, it does a constant-time comparison of X-Gateway-Auth against an
env var holding the same secret the gateway edge has, and — only on a match — reads
X-Gateway-Client-Id/X-Gateway-Scopes into whatever per-request context your framework uses. A
present-but-wrong X-Gateway-Auth is always a reject, never a fall-through to "trust the headers
anyway" or "treat as unauthenticated."
Node/Express:
const crypto = require('crypto');
function gatewayAuth(req, res, next) {
const clientId = req.header('X-Gateway-Client-Id');
if (clientId == null) {
return next(); // no machine-caller claim — normal session/Bearer auth handles this request
}
const provided = req.header('X-Gateway-Auth') || '';
const expected = process.env.GATEWAY_SHARED_HEADER_SECRET || '';
const ok = expected.length > 0 &&
Buffer.byteLength(provided) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) {
return res.status(401).json({ error: { code: 'APP1001', message: 'invalid gateway auth' } });
}
req.gateway = {
clientId,
scopes: (req.header('X-Gateway-Scopes') || '').split(' ').filter(Boolean),
};
next();
}
Python (Flask/Django-style middleware):
import hmac
import os
def gateway_auth_middleware(get_response):
def middleware(request):
client_id = request.headers.get("X-Gateway-Client-Id")
if client_id is None:
return get_response(request) # no machine-caller claim — normal session auth handles it
provided = request.headers.get("X-Gateway-Auth", "")
expected = os.environ.get("GATEWAY_SHARED_HEADER_SECRET", "")
if not expected or not hmac.compare_digest(provided, expected):
return json_response({"error": {"code": "APP1001", "message": "invalid gateway auth"}}, status=401)
request.gateway_client_id = client_id
request.gateway_scopes = request.headers.get("X-Gateway-Scopes", "").split()
return get_response(request)
return middleware
Go (net/http):
func gatewayAuth(secret string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Gateway-Client-Id")
if clientID == "" {
next.ServeHTTP(w, r) // no machine-caller claim — normal session auth handles this request
return
}
provided := r.Header.Get("X-Gateway-Auth")
if secret == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
writeError(w, http.StatusUnauthorized, "APP1001", "invalid gateway auth")
return
}
ctx := context.WithValue(r.Context(), gatewayClientIDKey, clientID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Ruby (Rails before_action):
before_action do
client_id = request.headers["X-Gateway-Client-Id"]
next if client_id.blank? # no machine-caller claim — normal session auth handles this request
provided = request.headers["X-Gateway-Auth"].to_s
expected = ENV.fetch("GATEWAY_SHARED_HEADER_SECRET", "")
unless expected.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
render json: { error: { code: "APP1001", message: "invalid gateway auth" } }, status: :unauthorized
return
end
@gateway_client_id = client_id
@gateway_scopes = request.headers["X-Gateway-Scopes"].to_s.split
end
Whatever your stack, the same rules apply:
X-Gateway-Client-Id presence first, not X-Gateway-Auth. Absent means "no machine
claim" — let your normal session/Bearer auth run exactly as it already does, unmodified. This is
now automatic composition, not something you opt into per route (CONTRACT.md §5.2) — every
scope-bearing route already serves both a human session and a granted machine caller this way.X-Gateway-Auth is always a reject, never a fall-through to your normal auth
path.X-Gateway-Scopes as authorization, separate from the identity check
above — a route can require a specific scope (e.g. your-app-slug:staff.read) beyond just "any
valid gateway caller." Don't conflate "the gateway vouches for this caller" with "this caller may
do this specific thing."GATEWAY_SHARED_HEADER_SECRET's value the same way you get any other secret: from your
secrets store, matching the value configured on the gateway edge. If you're registering a new app
and don't have it yet, POST /applications/{id}/credentials now returns it alongside your
clientId/clientSecret (README.md "The shared header secret") — one gateway-wide value, not
something generated specifically for your app.None of the above makes you reachable by itself — an admin still has to:
POST /applications {"manifestUrl": "https://your-app/.well-known/gateway-manifest"} against
the control plane.POST /applications/{consumerId}/grants {"providerAppSlug": "your-slug", "scopeKey": "..."} for
every other application that should be allowed to call you, and for which scope. Registration
never implies a grant — this is a separate, explicit step per consuming application.Before assuming your app's own code is at fault for an unexpected error (a GW1xxx error code
from the edge, or a request you expected to arrive that never did), check
GET /applications/{your-app-id}/requests against the control plane — every request the edge
classified for your app, machine and passthrough both, with method/status/duration/timestamp
(CONTRACT.md §14). GET /requests/summary gives the same thing aggregated across every app, if
you're not sure which application's traffic is actually the problem. This tells you, before you go
looking anywhere else, whether the request reached the edge at all and what it decided — a 401 GW1001 there means the edge itself rejected the token (expired, malformed, wrong iss/aud), not
something your own app's code did.
The shared-secret check in step 3 only protects you if your app isn't reachable by anything that
bypasses the gateway — otherwise anyone who can reach your app directly could set
X-Gateway-Client-Id themselves without ever knowing the secret... except they'd still need the
secret to pass step 3, so the real risk is a leaked secret, not a bypassed network path. Still:
prefer keeping your app off any publicly routable address and reachable only via the gateway/shared
internal network, so a leaked secret isn't your only line of defense.
Never hardcode another application's real internal address. Call the gateway's edge instead:
POST /applications/{yourAppId}/credentials against the
control plane. The response is {"clientId", "clientSecret", "sharedHeaderSecret"?} — the
clientId/clientSecret are yours alone and shown once (store them in your secrets manager,
never in code); sharedHeaderSecret, if present, is the same gateway-wide value from "Trust
the gateway's forwarded identity" above, only worth saving if you're also a provider.POST /applications/{yourAppId}/grants {"providerAppSlug": "...", "scopeKey": "..."} for every app + scope you actually need to call.
Having a credential doesn't grant you anything by itself.POST <control-plane>/oauth/token with
grant_type=client_credentials&client_id=...&client_secret=... returns a short-lived JWT
(~15 min) carrying exactly the scopes you currently hold. Cache it; re-mint shortly before (or
on a 401 from) expiry — don't mint a fresh token on every call.<gateway-edge>/<provider-slug>/<path> with
Authorization: Bearer <token>.The token-caching step looks the same regardless of stack — cache the token and its expiry
in-process, re-mint a little before it actually expires (a fixed safety margin, e.g. 30s, is
enough), and on an unexpected 401 from the edge, mint once more and retry the call exactly once
before giving up:
function callProvider(providerSlug, path, opts):
token = getCachedTokenOrMint() # mint() calls POST /oauth/token, caches token + expiry
response = httpCall(edgeUrl + "/" + providerSlug + path, bearer=token, ...opts)
if response.status == 401:
token = mint() # force a fresh mint, bypass the cache once
response = httpCall(edgeUrl + "/" + providerSlug + path, bearer=token, ...opts)
return response
A browser-based frontend calling its own backend with a user's login session is not a machine client, and should not be registered, credentialed, or granted a scope:
client_secret confidentially — anything shipped to a browser is visible to
whoever opens dev tools. The client_credentials flow assumes a confidential backend client; a
browser SPA structurally can't be one.scope empty in your manifest and point the frontend's API base
URL at <gateway-edge>/<your-slug> instead of your backend's own address (ADR-GW-2 — every
route is namespaced under your slug now, CONTRACT.md §5.1). The path portion of every call
your frontend already makes is unchanged — only the base URL/host changes, and only once you
actually want that traffic to flow through the gateway rather than hit your backend directly
(both are valid; see "Two separate questions" above). Nothing else about your frontend's auth
code changes; the edge just proxies the request, Authorization header untouched, and your
backend keeps verifying it exactly as before.CONTRACT.md
§10). If your frontend has a server-side layer where many real users' traffic shares one egress
IP, they'll share one rate-limit bucket; check whether that's actually your frontend's traffic
shape before assuming the default limit is fine.Everything above is deliberately generic — if you want to see a fully worked, real (not
pseudocode) integration to cross-check your own implementation against, talentscholarplus-backend-staging
(NestJS) has one. Predates ADR-GW-2 as of this writing — its gateway-or-jwt-auth.guard.ts may
still key off X-Gateway-Auth presence rather than X-Gateway-Client-Id (§3's corrected pattern
above); check it against the current code before copying, don't assume it's already migrated:
src/modules/well-known/gateway-manifest.classifier.ts — auto-discovered manifest generation from
the framework's own route registry.src/common/gateway/gateway-or-jwt-auth.guard.ts — the "accept either" composition of a gateway
check with an existing session-auth guard.src/common/gateway/require-gateway-scope.decorator.ts — enforcing a specific scope on top of the
identity check.src/services/gateway-client/gateway-client.service.ts — the consumer side: token caching,
refresh, and one retry on a 401.Nothing about the gateway's contract is NestJS- or TypeScript-specific — this is one example among however many stacks end up integrating, not the reference stack.
scope decided deliberately — empty for "my own frontend/human session"
traffic (still the case even though a route with a scope now composes with passthrough,
ADR-GW-2), a real scope only for genuine app-to-app calls with a specific grant in mindX-Gateway-Client-Id presence first — not X-Gateway-Auth,
which is stamped on passthrough traffic too — and only verifies the shared secret when it's
present, composing with (not replacing) your existing human-session auth automaticallyGATEWAY_SHARED_HEADER_SECRET set from your own secrets store, matching the edge's value
(get it from POST /applications/{id}/credentials if you don't have it yet, or from
whoever manages the edge's own config if you do)POST /applications) — and refreshed after any manifest
change (POST /applications/{id}/refresh)<gateway-edge>/<your-slug>, not your backend's own address (ADR-GW-2 — every route is
namespaced under your slug now, no raw/unnamespaced alias exists anymore)Fetch the org's gateway integration rule + checklist directly — no repo clone needed:
mkdir -p .claude/rules .claude/skills/gateway-integration-check curl -fsSL https://api.gateway.etdevops.io/claude/rules/api-gateway.md -o .claude/rules/api-gateway.md curl -fsSL https://api.gateway.etdevops.io/claude/skills/gateway-integration-check.md \ -o .claude/skills/gateway-integration-check/SKILL.md