Securely Exposing Enterprise APIs to Citizen Developers
TL;DR — Citizen developers will call your internal APIs whether you build a safe path or not. Build the safe path. Put Kong 3.8 in front, scope tokens to the workflow, rate-limit hard, log everything, and review quarterly. The threat model is mostly accidents, not adversaries.
Your finance ops lead has built a Make scenario that posts to your billing API. They got the API token from your last skip-level. The token has full admin scope because that was the only one they could find. They’ve shared the token in a Make connection that three other people can edit. The scenario runs hourly. Nobody on your platform team knows it exists.
I’m describing something I’ve seen, in some variation, at every company I’ve worked with that adopted low-code without a plan. The good news is the fix is not technically hard. It’s mostly platform discipline. This post is the playbook.
If you’ve read the self-hosting n8n piece and have the platform running, this is the next layer up — what happens when those workflows want to call your stuff.
The threat model is accidents
I want to be clear about this up front because it changes the recommendations. The biggest risk from citizen developers calling internal APIs is not malicious. It’s not data exfiltration. It’s accidents. Specifically:
- A workflow built for staging gets pointed at production
- A scope-creep change to a scenario doubles its request volume
- A token gets shared in a thread, then someone uses it from outside the workflow
- A loop bug fires the same destructive call ten thousand times before anyone notices
- A SaaS vendor in the workflow has an outage and the workflow retries forever
Build for these and you cover 95% of the actual risk. Build for nation-state attackers and you’ll never ship the platform.
The shape of the answer
The pattern that works has four layers between the citizen workflow and your internal services.
- A dedicated API gateway — Kong 3.8 in my case, but Apigee or Tyk or AWS API Gateway are fine. Different cluster from the public gateway.
- Per-workflow scoped tokens — never per-user, never shared. Bound to a specific workflow ID and a specific scope.
- Per-route rate limits and quotas — both per-second to stop bursts and per-day to stop runaways.
- Mandatory audit logging — to a SIEM, retained, queryable.
Each of those layers is doing a specific job. Skip any one and the others can’t compensate.
The gateway, Kong 3.8 setup
I’ll show this with Kong because it’s what I’ve shipped most recently and it’s solid in 2024. The same pattern works in any gateway.
The mental model — every internal API gets two routes. The “internal” route, which other services in your VPC hit directly. The “citizen” route, which goes through the gateway and is heavily restricted. The internal API itself only needs to authenticate one of them — the gateway — and trust the rest.
A Kong route declared via decK YAML for an invoices API:
_format_version: "3.0"
services:
- name: invoices-citizen
url: http://invoices.internal.svc.cluster.local:8080
routes:
- name: invoices-citizen-route
paths:
- /citizen/v1/invoices
strip_path: true
path_handling: v1
plugins:
- name: key-auth
config:
key_names: ["x-workflow-token"]
hide_credentials: true
- name: rate-limiting
config:
second: 5
hour: 1000
day: 5000
policy: redis
redis_host: redis-kong.svc.cluster.local
- name: request-size-limiting
config:
allowed_payload_size: 1 # MB
- name: acl
config:
allow: ["citizen-invoices-read", "citizen-invoices-write"]
- name: http-log
config:
http_endpoint: https://siem.internal.example.com/ingest/kong
method: POST
timeout: 1000
keepalive: 60000
What this gets you. A path the workflow calls. Auth via a header token Kong validates and strips before forwarding. Rate limits at three time scales. A payload size cap because nobody needs to POST 50MB to your invoicing service from n8n. An ACL plugin gating which token has which scope. And an HTTP log emitter for SIEM.
The internal service never sees the token. Kong forwards X-Consumer-Username and X-Consumer-Id headers, the service trusts those.
Per-workflow tokens, not per-user
The single biggest mistake I see is per-user tokens. The citizen dev has a personal API key. They put it in the workflow. They leave the company. The workflow keeps running until somebody notices the failures. Or worse, the workflow keeps running because the token hasn’t been revoked.
Tokens are bound to workflows. The provisioning flow looks like this:
- Citizen dev requests access via a form. They specify the workflow name, the scope they need, and the owning business unit.
- Platform team approves (or auto-approves for pre-allowed scope combinations).
- A token is provisioned in Kong, tagged with workflow ID, scope, owner.
- The token is delivered as an n8n credential the dev can reference but not read.
Provisioning code in Go that talks to the Kong admin API:
package provision
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
type Token struct {
Workflow string `json:"workflow"`
Owner string `json:"owner"`
Scopes []string `json:"scopes"`
}
type kongConsumer struct {
Username string `json:"username"`
Tags []string `json:"tags"`
}
type kongKey struct {
Key string `json:"key"`
}
func Provision(ctx context.Context, admin string, t Token) (string, error) {
username := fmt.Sprintf("wf-%s", t.Workflow)
tags := append([]string{"citizen", "owner:" + t.Owner}, t.Scopes...)
if err := postJSON(ctx, admin+"/consumers", kongConsumer{
Username: username,
Tags: tags,
}); err != nil {
return "", fmt.Errorf("create consumer: %w", err)
}
var keyResp kongKey
if err := postJSONResp(ctx, admin+"/consumers/"+username+"/key-auth", struct{}{}, &keyResp); err != nil {
return "", fmt.Errorf("create key: %w", err)
}
for _, scope := range t.Scopes {
if err := postJSON(ctx, admin+"/consumers/"+username+"/acls",
map[string]string{"group": scope}); err != nil {
return "", fmt.Errorf("attach acl: %w", err)
}
}
return keyResp.Key, nil
}
func postJSON(ctx context.Context, url string, body any) error {
return postJSONResp(ctx, url, body, nil)
}
func postJSONResp(ctx context.Context, url string, body any, out any) error {
b, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("status %d", res.StatusCode)
}
if out != nil {
return json.NewDecoder(res.Body).Decode(out)
}
return nil
}
The point of the snippet is not the Kong calls, which you can read in the Kong admin API docs. The point is that token provisioning is a programmatic, auditable workflow with workflow ID and owner attached. Not a manual share in Slack.
Rate limits, two scales
Kong’s rate-limiting plugin supports second, minute, hour, day. Set at least two. The short scale (second: 5) stops accidental bursts and runaway loops. The long scale (day: 5000 or whatever) stops the slow leak — a workflow that’s quietly making 200 requests an hour for a week before anyone notices.
The right numbers depend on the API. My defaults for a freshly provisioned citizen route:
- Per-second: 5–10 requests
- Per-hour: 500–2000
- Per-day: 5000–20000
If a workflow legitimately needs more, the owner files a ticket. The ticket is the friction. The friction is the point.
Audit, the part nobody wants to do
Every request through the citizen gateway logs to a SIEM. The minimum fields:
- Timestamp (RFC3339, UTC)
- Consumer ID (which translates to workflow ID)
- Route
- Method
- Response status
- Response time
- Source IP (which should be n8n’s egress)
- Request body hash (not body, just hash, for change-detection)
Retain for at least one year. SOC 2 will want this. ISO 27001 will want this. Even if you’re not certified, your own incident response will want this when the inevitable “why did the invoice service get hammered Tuesday night” question comes up.
I’ll cover the auditing piece in more depth in a couple of weeks when we get to compliance specifically.
Common pitfalls
A few patterns I’ve watched fail.
- One token for everything. “We’ll just give n8n one master API key and let workflows share it.” This makes per-workflow attribution impossible and turns every revocation into a cliff event. Per-workflow tokens, always.
- No path namespacing. Mixing the citizen gateway routes with public API routes means a misconfigured Kong plugin can leak between them. Run a separate Kong cluster, or at least separate workspaces and node pools.
- Skipping the request-size limit. Without it, a misconfigured workflow can post massive payloads and OOM your service. 1 MB is plenty for almost everything citizen workflows should be doing.
- Trusting the source IP for auth. I see this often — “the gateway is in our VPC, so we just check the source IP.” Then n8n’s egress IP changes during a node rotation and everything breaks. Use proper consumer attribution from the gateway, not IPs.
- Forgetting webhook direction. Tokens protect calls from n8n to you. They don’t protect calls from you to n8n’s webhooks. Sign those separately. Next post covers it.
- No revocation drill. Provisioning tokens is half the job. The other half is knowing how to kill a token in under thirty seconds when something goes wrong. Practice it.
Wrapping up
The right mental model for exposing APIs to citizen developers is “this is just an external partner integration,” with all the discipline that implies. Per-partner tokens, scoped permissions, rate limits, audit logs. The partner just happens to be your own marketing ops team. The discipline is the same.
If you’re already running Kong or Apigee for public partners, extending it for citizen devs is mostly a configuration job, not a build. If you’re not, this might be the trigger to put a gateway in. Either way, the alternative is the status quo, which is an undocumented production dependency you’ll discover during an incident.
Next post is on the inverse direction — when your services need to send webhooks to n8n or any other consumer, and how to make those reliable. Retries, idempotency, signatures. The boring stuff that breaks first.