zuplo 6.73.26 → 6.73.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,359 @@
1
+ ---
2
+ title: Authenticate a cloud workload identity
3
+ sidebar_label: Cloud Workload Identity
4
+ description:
5
+ Verify the OIDC identity token a GCP, AWS, or Azure workload mints from its
6
+ own ambient credentials, then pin the route to the workloads you allow.
7
+ tags:
8
+ - authentication
9
+ ---
10
+
11
+ A workload running in a cloud already has an identity: a Google service account,
12
+ a Kubernetes service account on EKS or AKS. On most of those platforms it can
13
+ get a short-lived OIDC identity token for that identity — minted on demand by
14
+ the metadata server on GCP, projected into the pod on EKS and AKS. The caller
15
+ then authenticates to your gateway without anyone creating, distributing, or
16
+ rotating a new secret.
17
+
18
+ Zuplo has no dedicated GCP or Azure inbound JWT policy, and no inbound AWS SigV4
19
+ verification policy. The path that works is the generic
20
+ [Open ID JWT Auth](../policies/open-id-jwt-auth-inbound.mdx) policy pointed at
21
+ the cloud's OIDC issuer, followed by
22
+ [Require User Claims](../policies/require-user-claims-inbound.mdx) to decide
23
+ which of that issuer's identities may call the route.
24
+
25
+ ## How it works
26
+
27
+ 1. The caller asks its platform for an identity token for a fixed audience
28
+ string.
29
+ 2. The JWT policy validates the signature against the issuer's JWKS, checks the
30
+ `iss` and `aud` claims, and populates `request.user`.
31
+ 3. A claims policy allows only the identities on your allowlist and returns
32
+ `403` to everything else.
33
+ 4. Later policies (rate limits, quotas, logging) group on the identity that step
34
+ 2 established.
35
+
36
+ Step 3 isn't optional. Every Google customer's workloads share the issuer
37
+ `https://accounts.google.com`, so on GCP the issuer check alone proves only that
38
+ _someone's_ Google service account made the call. The audience check and the
39
+ claims allowlist are what narrow that to your workloads. A cluster issuer on EKS
40
+ or AKS is already yours, but the claims rule is still what separates one
41
+ namespace or service account from every other one on the cluster.
42
+
43
+ ## Prerequisites
44
+
45
+ - A Zuplo project with at least one route.
46
+ - A caller running on a platform that issues OIDC identity tokens for its own
47
+ workload identity: Compute Engine, GKE, Cloud Run, or Cloud Build on GCP; EKS
48
+ with IAM Roles for Service Accounts or EKS Pod Identity on AWS; AKS workload
49
+ identity on Azure.
50
+ - Access to set an [environment variable](./environment-variables.mdx) on the
51
+ project.
52
+
53
+ ## 1/ Settle on a fixed audience
54
+
55
+ The gateway's job is to require one exact `aud` value. Settle on that value
56
+ first, and store it as an environment variable assigned to every environment:
57
+
58
+ ```
59
+ GATEWAY_AUDIENCE=https://api.example.com/agents
60
+ ```
61
+
62
+ Changing an environment variable requires a new deployment, so treat this value
63
+ as stable configuration rather than something to tune per branch.
64
+
65
+ How much freedom you have in choosing it belongs to the caller's platform, not
66
+ to the gateway. On GCP the caller passes any audience it likes as a query
67
+ parameter, per call. On EKS and AKS the audience is fixed on the projected-token
68
+ volume, so it is chosen once at deploy time — on AKS it defaults to
69
+ `api://AzureADTokenExchange`. On a token issued by Microsoft Entra ID it has to
70
+ be a registered Application ID URI, in the form `api://<app-client-id>`. Where
71
+ the platform constrains the value, the gateway's `audience` has to match what
72
+ the platform already issues rather than a value you invent.
73
+
74
+ :::caution{title="Keep the audience decoupled from the deployment URL"}
75
+
76
+ Where you do get to choose, reusing the deployment URL as the audience looks
77
+ tidy until the first preview build. Every branch deployment gets its own
78
+ hostname, so every branch would need its own audience, its own caller-side
79
+ configuration, and a redeploy to change. One constant that never varies across
80
+ environments keeps a single caller configuration working everywhere.
81
+
82
+ :::
83
+
84
+ ## 2/ Mint a token in the caller
85
+
86
+ On GCP, a workload reads an identity token straight from the metadata server,
87
+ with no SDK and no `gcloud` install required:
88
+
89
+ ```bash
90
+ AUDIENCE="https://api.example.com/agents"
91
+
92
+ TOKEN=$(curl -s -H "Metadata-Flavor: Google" \
93
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${AUDIENCE}")
94
+
95
+ curl -H "Authorization: Bearer ${TOKEN}" \
96
+ https://my-api-main-abc123.zuplo.app/agents/ping
97
+ ```
98
+
99
+ The decoded payload of a service account identity token looks like this:
100
+
101
+ ```json
102
+ {
103
+ "aud": "https://api.example.com/agents",
104
+ "azp": "112010400000000710080",
105
+ "email": "batch-runner@my-project.iam.gserviceaccount.com",
106
+ "email_verified": true,
107
+ "exp": 1745365618,
108
+ "iat": 1745362018,
109
+ "iss": "https://accounts.google.com",
110
+ "sub": "112010400000000710080"
111
+ }
112
+ ```
113
+
114
+ Two claims carry the caller's identity, and they are not interchangeable. `sub`
115
+ is the service account's immutable numeric unique ID. `email` is its address,
116
+ which is what a reviewer recognizes when reading an allowlist six months later.
117
+ Pick whichever your review process prefers, or match on either with an `or`
118
+ rule.
119
+
120
+ :::tip{title="Getting a token on a workstation"}
121
+
122
+ For local development,
123
+ `gcloud auth print-identity-token --audiences="$AUDIENCE"` prints the same kind
124
+ of token. Pass `--audiences` explicitly: without it you get a token with an
125
+ audience the gateway is not configured to accept, and a `401` that looks like a
126
+ signature problem. Add `--include-email` when printing a token for an
127
+ impersonated service account, otherwise the token carries no `email` claim and
128
+ an email allowlist rejects it.
129
+
130
+ :::
131
+
132
+ ### Other clouds
133
+
134
+ | Platform | Where the caller gets the token | `issuer` | `jwkUrl` |
135
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
136
+ | GCP service accounts (Compute Engine, GKE, Cloud Run, Cloud Build) | Metadata server identity endpoint, with the audience as a query parameter | `https://accounts.google.com` | `https://www.googleapis.com/oauth2/v3/certs` |
137
+ | AWS EKS (IAM Roles for Service Accounts, EKS Pod Identity) | Projected service account token, with the audience set on the volume | The cluster's OIDC issuer URL | The `jwks_uri` from the issuer's OpenID configuration document |
138
+ | AKS workload identity | Projected service account token, with the audience set on the volume | The cluster's OIDC issuer URL, from `az aks show --query oidcIssuerProfile.issuerUrl` | The `jwks_uri` from the issuer's OpenID configuration document |
139
+
140
+ On both EKS and AKS the token the pod receives is the cluster's own projected
141
+ service account token, so the subject is the Kubernetes service account —
142
+ `system:serviceaccount:<namespace>:<name>` — and the issuer is the cluster, not
143
+ the cloud's identity provider. A token issued by Microsoft Entra ID is a
144
+ different thing: getting one means the workload first exchanges its projected
145
+ token for an Entra token, its `aud` has to be a registered Application ID URI,
146
+ and its `sub` is an object-ID GUID rather than anything human-readable.
147
+ Verifying the cluster-issued token directly is the shorter path, and it is the
148
+ one this guide follows.
149
+
150
+ For any issuer other than Google, read `jwks_uri` out of the issuer's
151
+ `/.well-known/openid-configuration` document rather than guessing the path.
152
+
153
+ ## 3/ Verify the token at the gateway
154
+
155
+ ```json title="config/policies.json"
156
+ {
157
+ "name": "workload-identity-auth",
158
+ "policyType": "open-id-jwt-auth-inbound",
159
+ "handler": {
160
+ "export": "OpenIdJwtInboundPolicy",
161
+ "module": "$import(@zuplo/runtime)",
162
+ "options": {
163
+ "issuer": "https://accounts.google.com",
164
+ "audience": "$env(GATEWAY_AUDIENCE)",
165
+ "jwkUrl": "https://www.googleapis.com/oauth2/v3/certs"
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ Set `audience`. The option is optional in the schema and load-bearing in
172
+ practice: without it, every service account identity token Google issues — for
173
+ any project, aimed at any service — satisfies the policy.
174
+
175
+ Leave `allowUnauthenticatedRequests` unset. It defaults to `false`, which
176
+ returns `401` for a missing, expired, or wrong-audience token. Setting it to
177
+ `true` lets that request continue down the pipeline with no `request.user`
178
+ attached, and everything that depends on an identity then degrades quietly
179
+ rather than failing: `rateLimitBy: "user"` falls back to a single shared
180
+ `user-anonymous` bucket, so every unauthenticated caller competes for one
181
+ counter and any one of them can exhaust it for all the others. The route is then
182
+ only as closed as the next policy or the handler makes it. Set it to `true` only
183
+ in a deliberate fallback arrangement, where a second authentication policy runs
184
+ after this one. See [Multiple Auth Policies](./multiple-auth-policies.mdx) for
185
+ that pattern.
186
+
187
+ ## 4/ Pin which workloads may call the route
188
+
189
+ The JWT policy proves the caller holds a token from the issuer. The claims
190
+ policy decides which callers that covers. Match on a claim the token actually
191
+ carries: `email` exists on GCP service account identity tokens, and a projected
192
+ Kubernetes service account token has no `email` claim at all.
193
+
194
+ ```json title="config/policies.json"
195
+ {
196
+ "name": "allow-known-workloads",
197
+ "policyType": "require-user-claims-inbound",
198
+ "handler": {
199
+ "export": "RequireUserClaimsInboundPolicy",
200
+ "module": "$import(@zuplo/runtime)",
201
+ "options": {
202
+ "rule": {
203
+ "claim": "email",
204
+ "in": [
205
+ "batch-runner@my-project.iam.gserviceaccount.com",
206
+ "report-agent@my-project.iam.gserviceaccount.com"
207
+ ]
208
+ }
209
+ }
210
+ }
211
+ }
212
+ ```
213
+
214
+ This policy runs after an authentication policy, never instead of one. With no
215
+ authenticated user on the request it returns `401`; when the rule evaluates to
216
+ false it returns `403` without echoing claim values, and writes the failing
217
+ checks to the request log instead.
218
+
219
+ On EKS and AKS the subject is the Kubernetes service account,
220
+ `system:serviceaccount:<namespace>:<name>`, so a namespace prefix pins every
221
+ workload in one namespace at once:
222
+
223
+ ```json
224
+ {
225
+ "rule": {
226
+ "claim": "sub",
227
+ "startsWith": "system:serviceaccount:agents:"
228
+ }
229
+ }
230
+ ```
231
+
232
+ `startsWith` also covers AWS STS-issued tokens, whose subject is an assumed-role
233
+ ARN such as `arn:aws:sts::123456789012:assumed-role/my-role/<session>` — a
234
+ prefix match is the only workable rule there, because the trailing session name
235
+ changes on every call. That is a different token from the projected service
236
+ account token an EKS pod holds, so check which one your caller actually sends
237
+ before writing the rule.
238
+
239
+ For namespaced claims, array-valued group claims, and `and`/`or` nesting, see
240
+ the [Require User Claims](../policies/require-user-claims-inbound.mdx) policy
241
+ reference.
242
+
243
+ ## 5/ Rate limit per identity
244
+
245
+ ```json title="config/policies.json"
246
+ {
247
+ "name": "rate-limit-per-workload",
248
+ "policyType": "rate-limit-inbound",
249
+ "handler": {
250
+ "export": "RateLimitInboundPolicy",
251
+ "module": "$import(@zuplo/runtime)",
252
+ "options": {
253
+ "rateLimitBy": "user",
254
+ "requestsAllowed": 600,
255
+ "timeWindowMinutes": 1
256
+ }
257
+ }
258
+ }
259
+ ```
260
+
261
+ `rateLimitBy: "user"` groups on `request.user.sub`, which the authentication
262
+ policy earlier in the pipeline populates, so nothing here is specific to JWTs.
263
+ For Google tokens that subject is the numeric unique ID; set
264
+ `subPropertyName: "email"` on the JWT policy if you would rather the subject,
265
+ and therefore the rate limit bucket, carry the readable service account address.
266
+
267
+ [Rate Limiting](../policies/rate-limit-inbound.mdx) is available on every plan.
268
+ [Complex Rate Limiting](../policies/complex-rate-limit-inbound.mdx), which
269
+ counts several resources per request, is an Enterprise policy.
270
+
271
+ ## 6/ Attach the policies to the route
272
+
273
+ Nothing above takes effect until the three policies are listed on the route, in
274
+ pipeline order — authenticate, then authorize, then meter:
275
+
276
+ ```json title="config/routes.oas.json (excerpt)"
277
+ {
278
+ "x-zuplo-route": {
279
+ "policies": {
280
+ "inbound": [
281
+ "workload-identity-auth",
282
+ "allow-known-workloads",
283
+ "rate-limit-per-workload"
284
+ ]
285
+ }
286
+ }
287
+ }
288
+ ```
289
+
290
+ :::note
291
+
292
+ Per-request structured logging works on every plan through `context.log.info`
293
+ and `context.log.setLogProperties` — see [Logging](./logging.mdx) — so the
294
+ allowed identity can appear on the log line for the call it authorized. The
295
+ [Audit Log](../policies/audit-log-inbound.mdx) policy, which emits a CloudEvents
296
+ audit trail, is an Enterprise policy on top of that.
297
+
298
+ :::
299
+
300
+ ## 7/ Verify it worked
301
+
302
+ Run all three cases against the deployed route:
303
+
304
+ ```bash
305
+ # 1. A token for the configured audience, from an allowlisted identity: 200.
306
+ curl -o /dev/null -w "%{http_code}\n" \
307
+ -H "Authorization: Bearer ${TOKEN}" \
308
+ https://my-api-main-abc123.zuplo.app/agents/ping
309
+
310
+ # 2. A token minted for a different audience: 401.
311
+ OTHER=$(curl -s -H "Metadata-Flavor: Google" \
312
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.example.com/other")
313
+ curl -o /dev/null -w "%{http_code}\n" \
314
+ -H "Authorization: Bearer ${OTHER}" \
315
+ https://my-api-main-abc123.zuplo.app/agents/ping
316
+
317
+ # 3. No token at all: 401.
318
+ curl -o /dev/null -w "%{http_code}\n" \
319
+ https://my-api-main-abc123.zuplo.app/agents/ping
320
+ ```
321
+
322
+ A token from a workload that is not on the allowlist returns `403` rather than
323
+ `401` — the signature and audience were fine, the identity was not. That
324
+ distinction is the fastest way to tell a caller-configuration problem from an
325
+ authorization problem.
326
+
327
+ ## Limitations
328
+
329
+ - **No inbound AWS SigV4 verification policy.** Verifying a SigV4-signed request
330
+ means writing the verification in
331
+ [Custom Code](../policies/custom-code-inbound.mdx). This matters on platforms
332
+ where the ambient AWS credential is a signature rather than an OIDC token — a
333
+ plain EC2 instance or a Lambda function, for example. The outbound direction
334
+ has policies for it:
335
+ [AWS service auth](../policies/upstream-aws-service-auth-inbound.mdx) and
336
+ [AWS federated auth](../policies/upstream-aws-federated-auth-inbound.mdx) are
337
+ both Enterprise features and both in beta, and they are free to try in
338
+ development. Neither sets an `Authorization` header on its own — SigV4 signs
339
+ the exact final request, which is only known inside the handler, so the
340
+ policies resolve the credentials and the `awsLambdaHandler` or your own code
341
+ signs the request with `AwsClient.fromContext(context)`.
342
+ - **No dedicated GCP or Azure inbound JWT policy.** Zuplo ships
343
+ provider-specific inbound JWT policies for identity providers — Auth0, Okta,
344
+ Clerk, Firebase, Supabase, Cognito user pools — but none for cloud workload
345
+ identity. The generic OIDC policy covers it, which means you supply `issuer`
346
+ and `jwkUrl` yourself instead of picking a provider from a list.
347
+ - **Token lifetime belongs to the platform.** Mint a token per call or hold it
348
+ only in memory; never write one into configuration or a stored secret.
349
+
350
+ ## Additional resources
351
+
352
+ - [Authentication](../concepts/authentication.mdx) — every inbound
353
+ authentication method Zuplo supports and how `request.user` is populated.
354
+ - [Rate limiting](../rate-limiting/getting-started.mdx) — the full set of
355
+ identification strategies, including dynamic per-caller limits.
356
+ - [Request user](../programmable-api/request-user.mdx) — reading the
357
+ authenticated identity and its claims in a handler.
358
+ - [Securing your backend](./securing-your-backend.mdx) — the other half of the
359
+ request: how the gateway authenticates itself to your origin.
@@ -18,12 +18,21 @@ The `zuplo` npm package ships the full documentation at
18
18
  your project. An `AGENTS.md` file at the repo root tells agents to read those
19
19
  docs before writing any code — no network calls required.
20
20
 
21
- New projects scaffold both files automatically:
21
+ New projects get the bundled docs with the `zuplo` package, and
22
+ [`create-zuplo-api`](./cli/create-zuplo-api.mdx) asks which coding agents to
23
+ configure. Pick **OpenAI Codex** for an `AGENTS.md`, or name the agents up
24
+ front:
22
25
 
23
26
  ```bash
24
- npx create-zuplo-api@latest
27
+ npx create-zuplo-api@latest --agents codex
25
28
  ```
26
29
 
30
+ Each agent gets the instruction file it reads natively — `AGENTS.md` for Codex,
31
+ `CLAUDE.md` for Claude Code, `.cursorrules` for Cursor — plus a shared
32
+ `.mcp.json`. See
33
+ [Configuring AI coding agents](./cli/create-zuplo-api.mdx#configuring-ai-coding-agents)
34
+ for the full list.
35
+
27
36
  For existing projects on `zuplo` 0.66.0 or later, drop in the default
28
37
  `AGENTS.md`:
29
38
 
@@ -28,7 +28,16 @@ npx create-zuplo-api@latest
28
28
  - `--git` - Whether or not to initialize the project as a git repository
29
29
  - `--version-check` - Whether or not to check for an outdated version
30
30
  - `--install` - Whether or not to install packages
31
+ - `--verbose` - Enable verbose logging
31
32
  - `--yes` - Use saved preferences or defaults for unprovided options
33
+ - `--server-project` - Create a matching project on portal.zuplo.com and link
34
+ this working copy to it, skipping the interactive prompt. See
35
+ [Creating a matching portal project](#creating-a-matching-portal-project)
36
+ - `--account <account-name>` - The Zuplo account that will host the server
37
+ project. Implies `--server-project`
38
+ - `--agents <agents>` - Comma-separated list of AI coding agents to configure
39
+ (`claude`, `copilot`, `cursor`, `windsurf`, `codex`), or `none` to skip agent
40
+ setup. See [Configuring AI coding agents](#configuring-ai-coding-agents)
32
41
  - `-e, --example <example-name|github-url>` - An example to bootstrap the API
33
42
  with. You can use an example name from the official Zuplo repository or a
34
43
  public GitHub URL. The URL can use any branch and/or subdirectory
@@ -38,27 +47,151 @@ npx create-zuplo-api@latest
38
47
  the example separately: `--example-path foo/bar`
39
48
  - `-h, --help` - Display the help message
40
49
 
41
- ### Examples
50
+ ### Opting out with `--no-` flags
51
+
52
+ Each boolean option also accepts a `--no-` form that turns the feature off:
53
+
54
+ | Opt-out flag | Effect |
55
+ | --------------------- | --------------------------------------------------- |
56
+ | `--no-eslint` | Skip ESLint configuration |
57
+ | `--no-prettier` | Skip Prettier configuration |
58
+ | `--no-git` | Don't initialize the project as a git repository |
59
+ | `--no-install` | Don't install packages |
60
+ | `--no-version-check` | Don't check for an outdated version |
61
+ | `--no-server-project` | Don't create a matching project on portal.zuplo.com |
62
+
63
+ These flags matter most in CI and scripted use. `--yes` accepts whatever is
64
+ saved in your preferences for any option you didn't pass, so a value you chose
65
+ once on a workstation can carry into a later run. A `--no-` flag ignores saved
66
+ preferences and suppresses the prompt outright, which makes the result the same
67
+ on every machine:
68
+
69
+ ```bash
70
+ npx create-zuplo-api@latest my-api --yes --no-eslint --no-prettier --no-install --no-git
71
+ ```
72
+
73
+ If you pass both spellings of the same option, the affirmative flag wins — so
74
+ `--eslint --no-eslint` initializes ESLint. `--no-server-project` is the
75
+ exception: it always wins, even when combined with `--server-project` or
76
+ `--account`.
77
+
78
+ ### Configuring AI coding agents
79
+
80
+ Pass `--agents` to write instruction files and MCP configuration for the coding
81
+ agents you use. Each selected agent also gets the
82
+ [Zuplo agent skills](../build-with-ai.mdx#agent-skills), so it works from
83
+ accurate Zuplo documentation instead of training data. Claude Code enables them
84
+ through the `zuplo/tools` plugin marketplace in `.claude/settings.json`. For
85
+ every other agent, the CLI downloads the skill files into that agent's skills
86
+ directory, such as `.cursor/skills/`.
87
+
88
+ | Value | Agent | Files written |
89
+ | ---------- | -------------- | ------------------------------------------------- |
90
+ | `claude` | Claude Code | `CLAUDE.md`, `.claude/settings.json`, `.mcp.json` |
91
+ | `copilot` | GitHub Copilot | `.github/copilot-instructions.md`, `.mcp.json` |
92
+ | `cursor` | Cursor | `.cursorrules`, `.mcp.json` |
93
+ | `windsurf` | Windsurf | `.windsurfrules`, `.mcp.json` |
94
+ | `codex` | OpenAI Codex | `AGENTS.md`, `.mcp.json` |
95
+ | `none` | — | Nothing — skips agent setup |
96
+
97
+ Combine values with commas:
98
+
99
+ ```bash
100
+ npx create-zuplo-api@latest my-api --agents claude,cursor
101
+ ```
102
+
103
+ The CLI validates the list before scaffolding anything:
104
+
105
+ - An unrecognized agent name is an error. `--agents zed` fails and prints the
106
+ valid values.
107
+ - `none` can't be combined with a real agent. Use `--agents none` to skip agent
108
+ setup, or list only the agents you want.
109
+
110
+ ## Creating a matching portal project
111
+
112
+ By default, the CLI asks whether to create a matching project on
113
+ portal.zuplo.com and link your new working copy to it. Answering yes runs
114
+ `zuplo project create` and `zuplo link`, which creates the project in your Zuplo
115
+ account and writes `ZUPLO_ACCOUNT_NAME` and `ZUPLO_PROJECT_NAME` to a
116
+ `.env.zuplo` file in the project directory. The CLI then prints the portal URL
117
+ for the new project.
118
+
119
+ Pass `--server-project` to opt in without the prompt, or `--account <name>` to
120
+ also choose which account hosts the project:
121
+
122
+ ```bash
123
+ npx create-zuplo-api@latest my-api --account my-account
124
+ ```
125
+
126
+ Creating the project requires authentication. Run
127
+ [`zuplo login`](./authentication.mdx) first, or let `zuplo project create` walk
128
+ you through signing in. When you have access to exactly one account, the CLI
129
+ picks it automatically; with more than one and no `--account`, it asks which
130
+ account should host the project.
131
+
132
+ To keep everything local, pass `--no-server-project`:
133
+
134
+ ```bash
135
+ npx create-zuplo-api@latest my-api --no-server-project
136
+ ```
137
+
138
+ :::note
139
+
140
+ `--yes` and CI environments skip this step unless you pass `--server-project` or
141
+ `--account`. Unlike the other prompts, the answer isn't saved to your
142
+ preferences, so a yes on your workstation never becomes a yes in CI.
143
+
144
+ :::
145
+
146
+ In non-interactive runs the CLI can't ask which account to use. Unless your
147
+ credentials resolve to exactly one account, pair `--server-project` with
148
+ `--account`. Otherwise the CLI prints a note, leaves your local files in place,
149
+ and skips the portal project:
150
+
151
+ ```bash
152
+ npx create-zuplo-api@latest my-api --yes --account my-account
153
+ ```
154
+
155
+ If project creation or linking fails, the scaffolded files stay on disk and the
156
+ CLI tells you how to finish up — run `zuplo project create --name my-api` and
157
+ `zuplo link` from inside the project directory.
158
+
159
+ ## Examples
42
160
 
43
161
  The following examples show different ways to use `create-zuplo-api`:
44
162
 
45
- #### With Default Template
163
+ ### With Default Template
46
164
 
47
165
  ```bash
48
- npx create-zuplo-api@latest my-api
49
- cd my-api
50
- npm run dev
166
+ npx create-zuplo-api@latest
51
167
  ```
52
168
 
53
- You will then be asked the following prompts:
169
+ The CLI asks the following questions:
170
+
171
+ ```text
172
+ ? What is your project named? › my-api
173
+ ? Create a matching project on portal.zuplo.com? › No / Yes
174
+ ? Would you like to use ESLint? › No / Yes
175
+ ? Would you like to use Prettier? › No / Yes
176
+ ? Which AI coding agents would you like to configure? › - Space to select. Return to submit
177
+ ◯ Claude Code
178
+ ◯ GitHub Copilot
179
+ ◯ Cursor
180
+ ◯ Windsurf
181
+ ◯ OpenAI Codex
182
+ ◯ None
183
+ ```
184
+
185
+ Pass the directory as an argument to skip the first question, then start the
186
+ development server:
54
187
 
55
188
  ```bash
56
- What's your project named? my-api
57
- Would you like to use ESLint? No / Yes
58
- Would you like to use Prettier? No / Yes
189
+ npx create-zuplo-api@latest my-api
190
+ cd my-api
191
+ npm run dev
59
192
  ```
60
193
 
61
- #### With an Official Example from GitHub
194
+ ### With an Official Example from GitHub
62
195
 
63
196
  To create a new Zuplo API using an official example from the Zuplo GitHub
64
197
  repository, you specify the example name using the `--example` option.
@@ -70,7 +203,11 @@ npx create-zuplo-api@latest my-api --example my-example
70
203
  You can find the list of available examples in the
71
204
  [Zuplo examples repository](https://github.com/zuplo/zuplo/tree/main/examples).
72
205
 
73
- #### With any Public GitHub Repository
206
+ Examples ship their own linting, formatting, and agent configuration, so the
207
+ CLI skips those questions. It still asks about creating a matching project on
208
+ portal.zuplo.com, because that choice is independent of the template.
209
+
210
+ ### With any Public GitHub Repository
74
211
 
75
212
  To create a new Zuplo API using any public GitHub repository, you can specify
76
213
  the repository URL using the `--example` option.
@@ -75,4 +75,6 @@ For a complete list of commands and flags, run `zuplo --help` or see
75
75
  [Global Options](./global-options.mdx).
76
76
 
77
77
  To scaffold a new project without installing the CLI, see
78
- [create-zuplo-api](./create-zuplo-api.mdx).
78
+ [create-zuplo-api](./create-zuplo-api.mdx). It can also run `project create` and
79
+ `link` for you, so a new working copy comes out already connected to a project on
80
+ portal.zuplo.com.
@@ -0,0 +1,193 @@
1
+ ---
2
+ title: Upstream Credentials
3
+ ---
4
+
5
+ Every request that passes through Zuplo involves two separate authentication
6
+ decisions. The caller authenticates to the gateway, and the gateway
7
+ authenticates to whatever it calls next. The two rarely use the same credential,
8
+ and on most routes they don't even use the same scheme: a client might present a
9
+ Zuplo API key while the upstream expects an AWS SigV4 signature, a Google ID
10
+ token, or a vendor API key in a query string.
11
+
12
+ [Authentication](./authentication.mdx) covers the first decision. This page
13
+ covers the second — which credential the gateway attaches on the way out, which
14
+ policy attaches it, and where the credential itself lives.
15
+
16
+ ## Two directions, one request
17
+
18
+ An upstream credential is attached in the middle of the request pipeline, after
19
+ the caller's identity is known and before the request leaves for the origin.
20
+
21
+ <Diagram height="h-96">
22
+ <DiagramNode id="caller">Caller</DiagramNode>
23
+ <DiagramGroup id="zuplo" label="Zuplo API Gateway">
24
+ <DiagramNode id="verify" variant="zuplo">
25
+ Verify caller credential
26
+ </DiagramNode>
27
+ <DiagramNode id="enforce" variant="zuplo">
28
+ Authorize, rate limit, log
29
+ </DiagramNode>
30
+ <DiagramNode id="attach" variant="zuplo">
31
+ Attach upstream credential
32
+ </DiagramNode>
33
+ </DiagramGroup>
34
+ <DiagramNode id="upstream">Upstream</DiagramNode>
35
+ <DiagramEdge from="caller" to="zuplo" />
36
+ <DiagramEdge from="verify" to="enforce" fromSide="bottom" toSide="top" />
37
+ <DiagramEdge from="enforce" to="attach" fromSide="bottom" toSide="top" />
38
+ <DiagramEdge from="zuplo" to="upstream" />
39
+ </Diagram>
40
+
41
+ The ordering is what makes the pattern useful. By the time the credential is
42
+ attached, `request.user` is populated, so the same request can be authorized
43
+ with [Require User Claims](../policies/require-user-claims-inbound.mdx), rate
44
+ limited per identity, and logged with the caller's subject — and the credential
45
+ the upstream receives is one the caller never held.
46
+
47
+ That changes what a leaked caller credential is worth. A revoked gateway API key
48
+ takes its holder's access with it, and the upstream secret exists in one place
49
+ instead of one place per caller. It does not change what a caller with
50
+ legitimate access can do. Deciding whether a call _should_ happen is
51
+ authorization, rate limiting, and logging — separate policies, on the same
52
+ request.
53
+
54
+ ## Why every credential policy ends in `-inbound`
55
+
56
+ Zuplo's pipeline has exactly two policy stages.
57
+ [Inbound policies](../articles/policies.mdx) run before the handler sends the
58
+ request upstream. Outbound policies run on the response, on its way back to the
59
+ caller.
60
+
61
+ Attaching an upstream credential happens before the request leaves, so every
62
+ credential-injection policy is an inbound policy and carries the `-inbound`
63
+ suffix — including the ones whose entire purpose is the outbound call, like
64
+ `upstream-gcp-service-auth-inbound`. The suffix names the pipeline stage, not
65
+ the direction of the credential. It is not a typo.
66
+
67
+ The `-outbound` policies work on responses. That is where
68
+ [Secret Masking](../policies/secret-masking-outbound.mdx) belongs: it redacts
69
+ credentials that leaked into a response body before the caller — or an LLM
70
+ reading the response — ever sees them.
71
+
72
+ ## Choosing a policy
73
+
74
+ | The upstream expects | Policy | Availability | Pick it when |
75
+ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | A static key or token in one header | [`set-upstream-api-key-inbound`](../policies/set-upstream-api-key-inbound.mdx) | All plans | One secret, one header. The common case, and the default header is `Authorization`. |
77
+ | Several headers, or a value composed from more than one variable | [`set-headers-inbound`](../policies/set-headers-inbound.mdx) | All plans | The upstream wants a non-standard header name, two headers, or a key plus a tenant identifier. |
78
+ | A GCP ID token or a scoped Google access token, from a stored SA key | [`upstream-gcp-service-auth-inbound`](../policies/upstream-gcp-service-auth-inbound.mdx) | Enterprise | Cloud Run, Cloud Functions, or GKE behind IAP (`audience`), or a Google API that needs OAuth `scopes`. |
79
+ | A GCP ID token, with no service-account key stored anywhere | [`upstream-gcp-federated-auth-inbound`](../policies/upstream-gcp-federated-auth-inbound.mdx) | Enterprise | The same IAM-protected services, using Workload Identity Federation instead of a stored key. Audience-bound ID tokens only. |
80
+ | A self-signed JWT accepted by Cloud Endpoints or ESPv2 | [`upstream-gcp-jwt-inbound`](../policies/upstream-gcp-jwt-inbound.mdx) | Enterprise | Rarely. The policy's own page steers you to GCP service auth in most cases. |
81
+ | An access token from any OAuth 2.0 client-credentials endpoint | `upstream-oauth-client-credentials-inbound` | All plans | An internal authorization server, or any SaaS vendor that issues client-credentials tokens. Set `tokenUrl`, `clientId`, `clientSecret`, and `scope` or `audience` if the provider needs them. |
82
+ | An Entra ID (Azure AD) access token from the client-credentials flow | [`upstream-azure-ad-service-auth-inbound`](../policies/upstream-azure-ad-service-auth-inbound.mdx) | Enterprise | Azure App Service, Azure Functions, or any Entra-protected resource. It builds the Entra token URL for you from a tenant id. |
83
+ | A SigV4-signed request, credentials from a stored IAM key pair | [`upstream-aws-service-auth-inbound`](../policies/upstream-aws-service-auth-inbound.mdx) | Enterprise · Beta | Lambda or another AWS service, optionally through an STS AssumeRole. Read [AWS is different](#aws-is-different) first. |
84
+ | A SigV4-signed request, with no AWS keys stored anywhere | [`upstream-aws-federated-auth-inbound`](../policies/upstream-aws-federated-auth-inbound.mdx) | Enterprise · Beta | The same, using `AssumeRoleWithWebIdentity` instead of a stored key pair. |
85
+ | A Firebase admin token | [`upstream-firebase-admin-auth-inbound`](../policies/upstream-firebase-admin-auth-inbound.mdx) | All plans | Firestore or another Firebase service called with service-account permissions. |
86
+ | A Firebase token scoped to one end user | [`upstream-firebase-user-auth-inbound`](../policies/upstream-firebase-user-auth-inbound.mdx) | All plans | Firebase should apply that user's own permissions. `userId` can be read from `request.user`. |
87
+ | A JWT proving the call came from your gateway | [`upstream-zuplo-jwt-auth-inbound`](../policies/upstream-zuplo-jwt-auth-inbound.mdx) | Enterprise | Your own upstream, with no third-party identity provider in the picture. Claims can be copied from the caller. |
88
+ | Anything else | [`custom-code-inbound`](../policies/custom-code-inbound.mdx) | All plans | Query-string keys, HMAC signatures, or picking the credential from the caller's identity. |
89
+
90
+ :::info{title="Enterprise policies are free to try in development"}
91
+
92
+ The GCP, Azure AD, AWS, and Zuplo-JWT upstream policies above are Enterprise
93
+ features for production traffic, and free to use on any plan for development.
94
+ The two AWS policies are also in beta and may change in non-backward-compatible
95
+ ways. The same applies to two policies this pattern often pairs with:
96
+ [`audit-log-inbound`](../policies/audit-log-inbound.mdx) and
97
+ [`complex-rate-limit-inbound`](../policies/complex-rate-limit-inbound.mdx) are
98
+ Enterprise, while [`rate-limit-inbound`](../policies/rate-limit-inbound.mdx) and
99
+ [`quota-inbound`](../policies/quota-inbound.mdx) are not.
100
+
101
+ :::
102
+
103
+ Two policies in the catalog look like they belong in the table and don't.
104
+ `set-headers-outbound` sets headers on the **response**, so it can't
105
+ authenticate an upstream call. `custom-code-outbound` runs after the response
106
+ arrives — useful for reshaping a body, not for signing a request.
107
+
108
+ ### AWS is different
109
+
110
+ The two AWS policies do not set an `Authorization` header. SigV4 signs the exact
111
+ final request — method, path, query, headers, body hash — so a header computed
112
+ earlier in the pipeline would be wrong by the time the request left.
113
+
114
+ Instead, both policies resolve AWS credentials and register them on the request
115
+ context. Something later in the pipeline signs with them:
116
+
117
+ - The [AWS Lambda handler](../handlers/aws-lambda.mdx), which does it for you.
118
+ - Your own code, via `AwsClient.fromContext(context)` in a
119
+ [custom code policy](../policies/custom-code-inbound.mdx) or
120
+ [custom handler](../handlers/custom-handler.mdx).
121
+
122
+ If a route uses one of these policies and the upstream returns 403, check that
123
+ something is actually consuming the resolved credentials. The policy alone does
124
+ not change the outgoing request.
125
+
126
+ ### MCP routes
127
+
128
+ MCP routes have their own credential broker.
129
+ [`mcp-token-exchange-inbound`](../policies/mcp-token-exchange-inbound.mdx)
130
+ resolves gateway-managed upstream credentials in three modes: `user-oauth`
131
+ (per-user OAuth federation, tokens stored encrypted at rest and keyed to the
132
+ user's subject), `shared-oauth` (one gateway-wide grant an administrator
133
+ connects once), and `id-jag` (Cross-App Access token exchange, built on an
134
+ active IETF draft rather than a finalized RFC). Per-user upstream API keys are
135
+ not part of this set.
136
+
137
+ See
138
+ [Per-user OAuth to upstream MCP servers](../mcp-gateway/auth/upstream-oauth.mdx)
139
+ for how the outbound surface works, and
140
+ [Connect an upstream that uses an API key](../mcp-gateway/how-to/connect-upstream-api-key.mdx)
141
+ for upstreams with no OAuth at all.
142
+
143
+ ## Where the credential lives
144
+
145
+ Upstream secrets belong in
146
+ [environment variables](../articles/environment-variables.mdx), referenced from
147
+ policy configuration with `$env(VAR_NAME)` and from code with
148
+ `environment.VAR_NAME`. Values are encrypted at rest; values marked as secrets
149
+ are write-only, so they can't be read back after they're set.
150
+
151
+ Two behaviors surprise people:
152
+
153
+ - **A changed value needs a new deployment.** Variables are applied to an
154
+ environment at deploy time. Updating a credential without redeploying leaves
155
+ the old value in service.
156
+ - **The CLI and the Developer API set variables per branch.**
157
+ [`zuplo variable create`](../cli/variable-create.mdx) and
158
+ [`zuplo variable update`](../cli/variable-update.mdx) both take a `--branch`
159
+ flag and create a record for that branch alone.
160
+
161
+ :::caution{title="Set shared credentials in the Portal, not the CLI"}
162
+
163
+ There is no all-environments assignment in the CLI or the Developer API. Only
164
+ the
165
+ [environment-variable editor](../articles/environment-variables.mdx#environment-variable-editor)
166
+ in the Zuplo Portal can apply one variable to several environments at once —
167
+ open **Settings → Environment Variables** in your project and select every
168
+ environment the credential belongs to.
169
+
170
+ A credential seeded per branch looks correct in the environment where it was
171
+ created and is simply absent everywhere else. Nothing fails at build or deploy
172
+ time; the first sign of trouble is a 401 or 403 from the upstream on live
173
+ traffic, which can go unnoticed for days on a low-volume route.
174
+
175
+ :::
176
+
177
+ The strongest option is to store nothing. Both federated policies
178
+ ([GCP](../policies/upstream-gcp-federated-auth-inbound.mdx),
179
+ [AWS](../policies/upstream-aws-federated-auth-inbound.mdx)) exchange the
180
+ gateway's own OIDC identity for short-lived cloud credentials, so there is no
181
+ long-lived key to leak, rotate, or seed into an environment.
182
+
183
+ ## Related
184
+
185
+ - [Securing your backend](../articles/securing-your-backend.mdx) — the six ways
186
+ to make sure only your gateway can reach your origin.
187
+ - [Secure a GCP backend with Zuplo upstream auth](../articles/gke-with-upstream-auth-policy.mdx)
188
+ — an end-to-end walkthrough with GKE and Identity-Aware Proxy.
189
+ - [Authentication](./authentication.mdx) — the inbound half of the request.
190
+ - [Environment variables](../articles/environment-variables.mdx) — syntax,
191
+ environments, and deployment behavior.
192
+ - [Policies](../articles/policies.mdx) — how the inbound and outbound stages fit
193
+ together.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "6.73.26",
3
+ "version": "6.73.27",
4
4
  "type": "module",
5
5
  "description": "The programmable API Gateway",
6
6
  "author": "Zuplo, Inc.",
@@ -19,9 +19,9 @@
19
19
  "zuplo": "zuplo.js"
20
20
  },
21
21
  "dependencies": {
22
- "@zuplo/cli": "6.73.26",
23
- "@zuplo/core": "6.73.26",
24
- "@zuplo/runtime": "6.73.26",
22
+ "@zuplo/cli": "6.73.27",
23
+ "@zuplo/core": "6.73.27",
24
+ "@zuplo/runtime": "6.73.27",
25
25
  "@zuplo/test": "1.4.0"
26
26
  }
27
27
  }