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.

Two requests, one guard The page request and the specification fetch are separate requests, and gating only the first leaves the contract publicly readable while breaking the render. the browser with a session GET /reference/ the HTML page GET /openapi/api.yaml the specification identity-aware proxy both paths, one policy no session → no bytes

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.

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
What each configuration protects Gating only the page leaves the contract public, gating only the spec breaks the render, and gating both with credentialed fetch is the working configuration. only one of these three actually works page gated only reference renders fine spec URL is public contract leaks both gated, plain fetch nothing leaks fetch sends no session empty reference both gated + credentials nothing leaks session reaches the fetch renders and is protected

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.

Session expiry while the page is open Without expiry handling the specification fetch fails and the reference goes blank; with handling the page reloads and the edge guard drives a clean sign-in. no expiry handling session expires spec fetch is redirected blank reference, no message with expiry handling session expires redirect detected, reload clean sign-in, back where they were

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.