Authenticating Stoplight Elements Behind SSO
This guide is part of Stoplight Elements within Developer Portal Frameworks & UI Setup. It covers serving an embedded API reference to authenticated users only — a partner portal, an internal platform reference, or a customer-specific API. The failure that brings people here is distinctive: the login works, the page loads, and the reference renders as an empty shell with no error message anywhere.
Problem & Context
Stoplight Elements is a web component. It runs in the browser, takes a specification URL, fetches it, and renders. Every part of that happens on the client, which means the component itself can enforce nothing — any check it performed would be code the visitor already has and can skip.
That leads directly to the mistake. Teams put an identity-aware proxy in front of the documentation page, confirm that an unauthenticated visitor gets bounced to the login screen, and consider the reference protected. It is not: the specification lives at its own URL, and if that URL is not covered by the same policy, anyone can request it directly and read the entire contract without ever loading the page.
The same oversight produces the empty-shell symptom in reverse. If the specification is protected but the component’s fetch does not carry the session, the identity provider answers with a redirect or a 401, the component receives something that is not a specification, and it renders nothing. No console error, no message — just an empty reference.
Both problems have the same root: the page and the specification are two requests, and they must be treated as one protected unit.
Step-by-Step Solution
1. Put the guard at the edge, not in the component
Any identity-aware layer works. The requirement is that it sits in front of the origin and covers both paths:
# nginx: one auth_request handler guarding the page and the spec together.
location /reference/ {
auth_request /_auth;
error_page 401 = @signin;
try_files $uri $uri/ /reference/index.html;
}
location /openapi/ {
auth_request /_auth; # the same guard — this is the line people omit
error_page 401 = @signin;
add_header Cache-Control 'private, no-store' always;
}
location = /_auth {
internal;
proxy_pass http://sso-verifier/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length '';
proxy_set_header X-Original-URI $request_uri;
}
location @signin {
return 302 https://sso.example.com/login?rd=$scheme://$host$request_uri;
}
2. Protect the specification URL as well as the page
Prove the second location block is doing its job before going further:
curl -s -o /dev/null -w '%{http_code}\n' https://docs.example.com/openapi/api.yaml
302
A 200 here means the contract is public regardless of what the page does.
3. Keep the session cookie reaching the spec fetch
Elements fetches the specification itself, and by default that fetch may not carry credentials. Fetch the document in your own code and hand the component a parsed object instead:
<link rel="stylesheet" href="/vendor/elements/styles.min.css">
<div id="reference"></div>
<script src="/vendor/elements/web-components.min.js"></script>
<script>
(async () => {
const res = await fetch('/openapi/api.bundled.yaml', {
credentials: 'same-origin', // send the SSO session with the request
headers: { accept: 'application/yaml, text/yaml, application/json' },
});
// A redirect to the identity provider means the session expired mid-visit.
if (res.redirected || res.status === 401 || res.status === 403) {
location.reload(); // let the edge guard drive the sign-in
return;
}
const el = document.createElement('elements-api');
el.setAttribute('router', 'hash');
el.setAttribute('layout', 'sidebar');
el.apiDescriptionDocument = await res.text();
document.getElementById('reference').appendChild(el);
})();
</script>
4. Handle session expiry without a blank page
The reload above covers the common case. Add a visible fallback for the case where the reload also fails, so a reader never sees an unexplained empty page:
const el = document.createElement('elements-api');
// ...
setTimeout(() => {
if (!document.querySelector('elements-api [role="navigation"]')) {
document.getElementById('reference').innerHTML =
'<p>The reference could not be loaded. ' +
'<a href="/reference/">Reload</a> or sign in again.</p>';
}
}, 8000);
5. Verify unauthenticated access is actually refused
Both URLs, no session, asserted in CI:
#!/usr/bin/env bash
# assert-gated.sh — neither the page nor the spec may serve content without a session.
set -uo pipefail
BASE="${BASE:?set BASE, e.g. https://docs.example.com}"
fail=0
for path in /reference/ /openapi/api.bundled.yaml; do
code=$(curl -s -o /dev/null -w '%{http_code}' "${BASE}${path}")
case "${code}" in
301|302|303|401|403) echo "OK ${path} → ${code} (gated)" ;;
*) echo "FAIL ${path} → ${code} (served without a session)" >&2; fail=1 ;;
esac
done
# A redirect that still leaks the body is a real and easy mistake to make.
body=$(curl -s "${BASE}/openapi/api.bundled.yaml" | head -c 200)
case "${body}" in
*openapi:*|*'"openapi"'*) echo 'FAIL spec body served despite the redirect' >&2; fail=1 ;;
*) echo 'OK no spec content in the unauthenticated response' ;;
esac
exit "${fail}"
OK /reference/ → 302 (gated)
OK /openapi/api.bundled.yaml → 302 (gated)
OK no spec content in the unauthenticated response
Complete Working Example
The full page, with the credentialed fetch, expiry handling, and a console routed through a proxy that holds the API credential server-side:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Acme Partner API reference</title>
<link rel="stylesheet" href="/vendor/elements/styles.min.css">
<style>
#reference { min-height: 60vh; }
.reference-error { padding: 1.5rem; }
</style>
</head>
<body>
<div id="reference"><p class="reference-error">Loading the reference…</p></div>
<script src="/vendor/elements/web-components.min.js"></script>
<script>
const MOUNT = document.getElementById('reference');
function showError(message) {
MOUNT.innerHTML =
'<p class="reference-error">' + message +
' <a href="/reference/">Reload</a></p>';
}
(async () => {
let res;
try {
res = await fetch('/openapi/api.bundled.yaml', {
credentials: 'same-origin', // carry the SSO session
cache: 'no-store', // never reuse a gated response
});
} catch (err) {
showError('The reference could not be reached.');
return;
}
// The edge guard bounced us — the session expired while the page was open.
if (res.redirected || res.status === 401 || res.status === 403) {
location.reload();
return;
}
if (!res.ok) {
showError('The reference returned ' + res.status + '.');
return;
}
MOUNT.innerHTML = '';
const el = document.createElement('elements-api');
el.setAttribute('router', 'hash');
el.setAttribute('layout', 'sidebar');
el.setAttribute('hideTryIt', 'false');
// Console requests go to a same-origin proxy that attaches the API
// credential server-side; the browser never holds one.
el.setAttribute('tryItCredentialsPolicy', 'same-origin');
el.apiDescriptionDocument = await res.text();
MOUNT.appendChild(el);
// Last-resort guard against a silent render failure.
setTimeout(() => {
if (!MOUNT.querySelector('elements-api *')) {
showError('The reference did not render.');
}
}, 8000);
})();
</script>
</body>
</html>
The cache: 'no-store' is easy to overlook and matters: a gated specification cached by the browser can be served to a later visitor on a shared machine after the session has ended, which defeats the guard entirely for exactly the population most likely to be affected by it.
Gotchas & Edge Cases
A 302 that still returns a body. Some proxies emit a redirect status while also writing the original response, so the check “did it redirect?” passes while the content leaks. Assert on the body as well as the status, as the verification script does.
The specification is cached by a CDN in front of the guard. If caching happens before authentication, the first authenticated request populates a cache entry that is then served to everyone. Mark gated paths private, no-store and confirm the CDN honours it.
credentials: 'same-origin' is not enough cross-origin. If the spec is served from a different host than the page, you need credentials: 'include' and a CORS policy that allows credentials with an explicit origin. The simpler fix is to serve both from the same origin, which also removes the CORS question entirely.
The try-it console does not inherit the docs session. A session that authenticates a reader to your documentation almost never authenticates them to the API. Route console requests through a same-origin proxy, as covered in Proxying CORS for API try-it consoles.
Search indexing behind SSO fails silently. A crawler with no session sees the login page for every URL and indexes that. Either give the crawler a service account, or accept that a gated reference has no external search and rely on in-page navigation.
Think about what the reference reveals even to authorised readers. Single sign-on answers “is this person allowed in?” and nothing more. If your partners each see the same complete reference, every one of them learns about endpoints intended for other partners, features not yet offered to them, and internal-facing operations that happen to share the specification. Where that matters, the answer is not a finer-grained component — it is a filtered artifact per audience, built the same way as the public and internal split described in Hiding internal endpoints in Swagger UI, and served from a path that the identity layer maps to the right audience.
Test the expired-session path deliberately, because it is the one users hit. Everybody tests a fresh login; almost nobody tests what happens when a reader leaves a reference open over lunch and comes back. That is precisely when the specification fetch is retried, the guard bounces it, and a component with no error handling renders nothing at all. Reproduce it by clearing the session cookie in devtools and navigating within the reference — if the result is anything other than a clean redirect to sign-in, the handling above is not wired correctly.
FAQ
Can Stoplight Elements enforce authentication itself?
No. It is a client-side component that fetches a specification URL; anything it could check runs in the browser and can be bypassed. Authentication belongs in front of the origin serving both the page and the specification.
Why does the reference render empty behind SSO?
The page loaded with a session but the specification fetch did not send one, so the identity provider returned a redirect or a 401 and the component received something that is not a document. Set the fetch credentials mode and gate both URLs with the same policy.
Does the try-it console still work behind SSO?
Only if the API accepts the same session, which it usually does not. Route console requests through a same-origin proxy that exchanges the documentation session for an API credential server-side.
Related
- Stoplight Elements — the parent guide and the component’s configuration surface.
- Embedding Stoplight Elements in React — the same mount inside a React application.
- Proxying CORS for API try-it consoles — keeping the console working behind the guard.
- Hiding internal endpoints in Swagger UI — the alternative when only part of the API is sensitive.