Building Tirraflow Part 2: Keycloak Silent SSO in React, No Loading Screens

In Part 1, I mentioned that most Keycloak tutorials block the entire app with a "Loading authentication..." screen. That's a dealbreaker for a blog — most visitors are anonymous readers who don't...

In Part 1, I mentioned that most Keycloak tutorials block the entire app with a "Loading authentication..." screen. That's a dealbreaker for a blog — most visitors are anonymous readers who don't care about auth.

So I made it non-blocking: the app renders instantly, Keycloak resolves in the background. Here's how.


The Problem

The textbook approach gates your entire app behind Keycloak initialization:

// Don't do this
if (!keycloakReady) return <div>Loading authentication...</div>;
return <YourApp />;

Every page load pays 200-500ms of blank screen. For a blog where 95% of visitors never log in, that's unacceptable.


The Fix: Render First, Auth Later

The Keycloak Singleton

// src/api/keycloak.js
const keycloak = new Keycloak({
  url: import.meta.env.VITE_KEYCLOAK_URL,
  realm: import.meta.env.VITE_KEYCLOAK_REALM,
  clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
});

let initPromise = null;

export const init = () => {
  if (!initPromise) {
    initPromise = keycloak.init({
      onLoad: 'check-sso',
      silentCheckSsoRedirectUri:
        window.location.origin + '/silent-check-sso.html',
      pkceMethod: 'S256',
    });
  }
  return initPromise;
};

Three things matter here:

The Silent Check Page

The iframe redirects to this 3-line HTML file, which posts the tokens back to the parent window:

<!-- public/silent-check-sso.html -->
<html><body><script>
  parent.postMessage(location.href, location.origin);
</script></body></html>

How It All Connects

Silent SSO sequence: the app loads, a hidden iframe checks the Keycloak session and posts the result back to the page

The entire flow completes in ~200ms. The user never sees any of it.

The Non-Blocking Provider

// src/contexts/AuthProvider.jsx (simplified)
function AuthProvider({ children }) {
  const [initialized, setInitialized] = useState(false);
  const [authenticated, setAuthenticated] = useState(false);

  useEffect(() => {
    init()
      .then((auth) => {
        setAuthenticated(auth);
        if (auth) { /* extract user, roles */ }
        setInitialized(true);
      })
      .catch(() => setInitialized(true)); // render anyway
  }, []);

  // No loading gate — children render immediately
  return (
    <AuthContext.Provider value={{ initialized, authenticated }}>
      {children}
    </AuthContext.Provider>
  );
}

The key: no if (!initialized) return <Loading />. Children render right away. When Keycloak finishes, auth-dependent components re-render. The homepage, articles, categories — they all load instantly.

For the rare page that needs auth (profile, dashboard), the initialized flag is available:

if (!initialized) return <ProfileSkeleton />;
if (!authenticated) return <Navigate to="/" />;

Token Refresh: Two Layers

Tokens expire. If you don't handle this well, users get randomly logged out. I use two layers:

Two layers of token refresh: a proactive request interceptor and a retry on 401 responses
// Request interceptor — proactive
client.interceptors.request.use(async (config) => {
  if (keycloak.authenticated && keycloak.isTokenExpired()) {
    await keycloak.updateToken(30);
  }
  if (keycloak.token) {
    config.headers.Authorization = `Bearer ${keycloak.token}`;
  }
  return config;
});

// Response interceptor — reactive
client.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    try {
      await keycloak.updateToken(5);
      error.config.headers.Authorization = `Bearer ${keycloak.token}`;
      return client.request(error.config);
    } catch { keycloak.login(); }
  }
  return Promise.reject(error);
});

Layer 1 handles 99% of cases. Layer 2 catches edge cases — clock skew, tokens expiring mid-flight, race conditions. Individual API calls never need to handle 401s.


Roles: Frontend Shows, Backend Enforces

Keycloak stores roles in two places in the JWT. You need both:

const realmRoles = keycloak.tokenParsed?.realm_access?.roles || [];
const clientRoles =
  keycloak.tokenParsed?.resource_access?.['tirraflow']?.roles || [];
const roles = [...new Set([...realmRoles, ...clientRoles])];

The AuthProvider computes permissions from these roles:

const canEditPost = (post) =>
  isAdmin || isEditor || post.author_id === user?.id;

Components just consume useAuth() and never think about role logic. But the frontend is purely cosmetic — the Go backend validates every request independently using the same JWT claims.


The Nginx Header That Makes It All Work

Silent SSO relies on an iframe embedding Keycloak. Browsers block this by default. You need one CSP header on the Keycloak server:

# On auth.tirraflow.com
add_header Content-Security-Policy
    "frame-ancestors 'self' https://tirraflow.com" always;

Without this, the iframe silently fails. No error. No warning. Users just appear permanently logged out.

Also bump proxy buffers — Keycloak's redirect responses are large:

proxy_buffer_size 128k;
proxy_buffers 4 256k;

Default Nginx buffers (4k) will truncate them, causing random 502s during login.


Pitfalls I Hit

  1. Silent iframe failures are invisible. Wrong CSP? The iframe just doesn't work. No console error. Check frame-ancestors first.

  2. Token refresh race conditions. Three concurrent API calls with an expired token = three refresh attempts. keycloak-js handles this internally, but be careful wrapping it in custom logic.

  3. Clock skew. Server clock 2 seconds ahead of client? Valid-looking tokens get rejected. The two-layer refresh catches this.

  4. PKCE is not optional. No client secret in a SPA means anyone intercepting the auth code can exchange it for tokens. pkceMethod: 'S256' is one line. Just do it.


The Result

No custom auth system. No loading screens. Just careful orchestration.


Update: Keycloak Moved Same-Origin

This post originally served Keycloak from auth.tirraflow.com and allowed the SPA to frame it with frame-ancestors. That worked, until browsers started partitioning cookies inside cross-site iframes. The silent-SSO iframe could no longer see Keycloak's session cookie, and the symptom was exactly the one this post set out to avoid: logged out on every refresh.

The fix was to stop being cross-site. Keycloak now lives under /auth on the same origin as the blog, so the iframe is first-party and shares the cookie jar:

# nginx — Keycloak served same-origin
location /auth/ {
    proxy_pass http://keycloak_upstream;
    proxy_buffer_size       128k;
    proxy_buffers         4 256k;
    proxy_busy_buffers_size 256k;
}

Keycloak needs to know about the prefix in two places, and they must agree: KC_HTTP_RELATIVE_PATH=/auth and KC_HOSTNAME=https://tirraflow.com/auth. Keycloak builds issuer and redirect URLs from the hostname and does not append the relative path, so leaving /auth off the hostname produces tokens with the wrong issuer.

A second trap arrived with the Content-Security-Policy. The silent-check page used an inline <script>, which script-src 'self' blocks, so keycloak-js never heard back from the iframe. The callback now lives in an external silent-check-sso.js, both files are served with Cache-Control: no-store so a stale copy can't break auth again, and the CSP is simply frame-src 'self' and connect-src 'self'. The old subdomain still exists, but only as a redirect to /auth.


What's Next

Part 3: E2E Testing a React SPA with Playwright — testing the whole frontend with no backend at all.


The Series

Building Tirraflow is a 5-part series on how the platform you are reading was built and run; every part is linked below.

Part

Topic

Part 1

The stack, the architecture, and what I learned shipping it

Part 2 (this post)

Non-blocking auth for a SPA, and why Keycloak ended up same-origin

Part 3

Testing the whole frontend with no backend at all

Part 4

From push to production, and how the pipeline learned to roll back

Part 5

How it got in, and the six layers that keep it out

Read on Tirraflow