Hiding Internal Endpoints in Swagger UI
This guide is part of Swagger UI Customization within Developer Portal Frameworks & UI Setup. It covers keeping admin, debug, and unreleased operations out of a public API reference — and specifically why the obvious approaches do not actually remove them. The situation arises constantly: one specification describes the whole API, and only part of it should be public.
Problem & Context
Swagger UI renders whatever document you give it, and the document is fetched by the browser. That single fact rules out every client-side approach.
A CSS rule that hides an operation block leaves the operation in the JSON the browser already downloaded. A requestInterceptor or a plugin that filters the rendered list does the same. Anyone who opens the network tab, or simply requests the spec URL directly, sees the complete API — including the admin endpoints, the debug routes, and the feature you have not announced yet.
This matters beyond embarrassment. An internal operation in a public specification is a documented attack surface: it tells an attacker the path, the parameters, the auth scheme, and the response shape. The authorisation on that endpoint had better be perfect, because you have just published its interface.
The only approach that actually works is to publish a different document — one produced by removing the internal parts at build time, verified before it ships.
Step-by-Step Solution
1. Mark internal operations in the specification
Use one extension consistently. It can sit on operations, parameters, or schema properties:
paths:
/invoices:
get:
operationId: listInvoices
summary: List invoices
responses: { '200': { description: OK } }
/admin/invoices/{id}/force-settle:
post:
operationId: forceSettleInvoice
x-internal: true # never published
summary: Force-settle an invoice (support tooling)
responses: { '204': { description: Settled } }
components:
schemas:
Invoice:
type: object
properties:
id: { type: string }
risk_score:
type: number
x-internal: true # field-level: hidden from the public schema
description: Internal fraud signal.
2. Filter the document at build time
Walk the document, drop every node carrying the marker, and write a separate public artifact:
// scripts/filter-internal.js
const fs = require('fs');
const yaml = require('js-yaml');
const MARKER = 'x-internal';
/** Recursively remove any node whose MARKER is truthy. */
function strip(node) {
if (Array.isArray(node)) {
return node.filter((v) => !(v && typeof v === 'object' && v[MARKER])).map(strip);
}
if (node && typeof node === 'object') {
const out = {};
for (const [k, v] of Object.entries(node)) {
if (k === MARKER) continue; // drop the marker itself
if (v && typeof v === 'object' && v[MARKER]) continue; // drop the marked node
out[k] = strip(v);
}
return out;
}
return node;
}
const doc = yaml.load(fs.readFileSync(process.argv[2], 'utf8'));
const pub = strip(doc);
// A path item left with no operations is an empty object — remove it too.
for (const [p, item] of Object.entries(pub.paths ?? {})) {
const methods = Object.keys(item).filter((k) =>
['get', 'put', 'post', 'delete', 'patch', 'head', 'options'].includes(k));
if (methods.length === 0) delete pub.paths[p];
}
fs.writeFileSync(process.argv[3], yaml.dump(pub, { lineWidth: 100 }));
console.log(`public paths: ${Object.keys(pub.paths ?? {}).length}`);
node scripts/filter-internal.js openapi/api.bundled.yaml dist/api.public.yaml
public paths: 18
3. Prune orphaned components
Removing operations leaves schemas nothing references. They are harmless but they are still published, so strip them:
# Remove components no longer referenced anywhere in the public document.
node -e '
const fs=require("fs"), yaml=require("js-yaml");
const doc=yaml.load(fs.readFileSync("dist/api.public.yaml","utf8"));
let changed=true;
while (changed) {
changed=false;
const text=JSON.stringify({paths:doc.paths, components:doc.components});
for (const kind of Object.keys(doc.components??{})) {
for (const name of Object.keys(doc.components[kind])) {
const ref=`#/components/${kind}/${name}`;
// count references excluding the definition itself
const uses=(text.match(new RegExp(ref.replace(/[/]/g,"\\\\/"),"g"))||[]).length;
if (uses===0) { delete doc.components[kind][name]; changed=true; }
}
}
}
fs.writeFileSync("dist/api.public.yaml", yaml.dump(doc,{lineWidth:100}));
'
npx @redocly/cli@2 lint dist/api.public.yaml
The loop repeats because pruning one schema can orphan another that only it referenced.
4. Assert nothing leaked
This is the step that turns a filter into a guarantee. Fail the build on any trace:
#!/usr/bin/env bash
# assert-no-internal.sh — refuse to publish an artifact containing internal material.
set -euo pipefail
PUBLIC="${1:-dist/api.public.yaml}"
fail=0
# a. The marker itself must be gone.
if grep -q 'x-internal' "${PUBLIC}"; then
echo "FAIL ${PUBLIC} still contains x-internal markers" >&2; fail=1
fi
# b. Known-internal path prefixes must not appear.
for prefix in /admin /internal /debug /_ops; do
if grep -q "\"\\?${prefix}" "${PUBLIC}"; then
echo "FAIL ${PUBLIC} contains an operation under ${prefix}" >&2; fail=1
fi
done
# c. Operation-count sanity: the public doc must be smaller than the source.
src=$(yq -r '[.paths[] | keys[]] | length' openapi/api.bundled.yaml)
pub=$(yq -r '[.paths[] | keys[]] | length' "${PUBLIC}")
echo "operations: source ${src}, public ${pub}"
[ "${pub}" -lt "${src}" ] || { echo 'FAIL nothing was filtered' >&2; fail=1; }
exit "${fail}"
operations: source 31, public 24
5. Publish the internal reference separately
Internal consumers still need documentation. Build a second artifact from the unfiltered document and put access control in front of it:
# Public: filtered, on the public origin.
npx @redocly/cli@2 build-docs dist/api.public.yaml -o dist/public/index.html
# Internal: complete, behind an identity-aware proxy on a separate host.
npx @redocly/cli@2 build-docs openapi/api.bundled.yaml -o dist/internal/index.html
Complete Working Example
The publish job, with the leak assertion as a required step between building and deploying:
# .github/workflows/publish-reference.yml
name: Publish reference
on:
push:
branches: [main]
paths: ['openapi/**']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Bundle the complete specification
run: npx @redocly/cli@2 bundle openapi/api.yaml -o openapi/api.bundled.yaml
- name: Produce the public document
run: |
node scripts/filter-internal.js openapi/api.bundled.yaml dist/api.public.yaml
node scripts/prune-orphans.js dist/api.public.yaml
# The public document must still be a valid, self-consistent spec.
- name: Validate the public document
run: npx @redocly/cli@2 lint dist/api.public.yaml
# REQUIRED GATE — nothing publishes if this fails.
- name: Assert no internal material leaked
run: ./scripts/assert-no-internal.sh dist/api.public.yaml
- name: Build the public reference
run: npx @redocly/cli@2 build-docs dist/api.public.yaml -o dist/public/index.html
- name: Deploy the public reference
run: aws s3 sync dist/public/ s3://docs-production/reference/ --delete
Ordering matters here as much as anywhere in this guide. The assertion runs after filtering and validation but before any build or deploy step, so a filter that silently stopped working — a renamed extension, a refactor that moved operations — fails the pipeline rather than shipping.
Gotchas & Edge Cases
A $ref to a removed schema breaks the public document. If a public operation references a schema you marked internal, filtering produces an unresolvable pointer. The lint step catches it; without that step you publish a reference whose schema tables are empty. Decide deliberately whether the schema is internal or the operation is.
Field-level filtering changes the public contract. Removing risk_score from the published Invoice schema means the public document no longer describes a field the API actually returns. That is usually correct — consumers should not depend on it — but it makes the public specification an incomplete description of real responses, which will surface in contract testing. Exclude internal fields from response validation, or accept them as documented-optional.
Tag-based hiding is not the same as filtering. Some tooling can hide operations by tag at render time. That is display logic with the same weakness as CSS: the document still contains everything. Use tags for organisation, and the extension for exclusion.
The internal build needs the same care as the public one. An “internal” reference on an unauthenticated URL that nobody links to is public. Gate it at the edge with an identity-aware proxy, and confirm the gate with an unauthenticated request in CI.
Marking is easy to forget on new operations. The filter only removes what somebody remembered to mark, and a new admin endpoint added under time pressure will not be. Make the default safe rather than relying on memory: add a lint rule requiring every operation under a known-internal path prefix to carry the marker, so an unmarked /admin route fails the specification build rather than appearing in the public reference.
Nothing filtered is a bug, not a pass. A run where the public and source operation counts match means the marker was renamed, misspelled, or lost in a refactor. The count assertion in step 4 exists specifically to catch this, because a filter that silently does nothing produces a successful build.
When evaluating any proposed approach, one question settles it immediately:
FAQ
Can I just hide internal operations with CSS?
No. A hidden element is still in the document the browser downloaded, so anyone can read it in the page source or the network tab, or request the specification URL directly. Hiding changes what is displayed, not what is published.
Why does filtering break my schema references?
Removing an operation can leave components that only it referenced, and removing a schema that a public operation still references produces an unresolvable $ref. Prune orphans after filtering and validate the result, which turns both cases into build failures instead of broken pages.
Should internal endpoints be in the same specification at all?
Usually yes, because one document keeps the contract consistent and lets the same linting, diffing, and testing cover everything. The separation belongs at publish time rather than in the source.
Related
- Swagger UI Customization — the parent guide and the wider customization surface.
- Interactive API Consoles & Try-It Panels — why a console must never be able to call a filtered operation.
- Splitting and bundling OpenAPI with Redocly CLI — producing the bundle this filters.
- Spec Linting & Governance — enforcing that the marker is used consistently.