Every tutorial on Single Sign-On starts the same way.
"Add Auth0." "Use Keycloak." "Configure Okta."
And if you're building enterprise software for thousands of users, that's probably the right answer. But if you're building your own tools — a homelab, a personal ecosystem, a suite of self-hosted apps — those solutions are massive overkill. You're importing a Saturn V rocket to cross the street.
So I built my own SSO. And here's what nobody told me before I did.
What SSO actually is (stripped of the marketing)
At its core, SSO is embarrassingly simple: one login that works across multiple apps.
The user logs in once. Gets a token. That token is trusted by every app in the ecosystem. Done.
The complexity that Auth0 and Keycloak solve isn't the concept — it's the edge cases. Revocation, refresh tokens, multi-tenant isolation, enterprise federation, compliance auditing. If you don't need those things, you don't need those tools.
For a self-hosted ecosystem of your own apps, the requirements are much simpler:
- One central auth service (the Hub)
- Apps trust tokens issued by the Hub
- Clicking "Open" in the Hub opens an app already logged in
- No user management duplication across apps
That's it. And you can build that with JWT and about 50 lines of Node.js.
How NEXUS Hub SSO works
I built this for NEXUS Ecosystem — a suite of six self-hosted Docker management tools. Here's the actual implementation, without the theory.
Step 1: Hub issues a short-lived SSO token
When the user clicks "Open" on a tool in the Hub dashboard, the Hub calls that tool's /api/auth/sso-url endpoint with its API key:
// Hub backend — generate SSO URL for a tool
async function openWithSSO(svc) {
const r = await fetch(`${svc.internalUrl}/api/auth/sso-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.NEXUS_API_KEY
},
body: JSON.stringify({
user: currentUser.username,
role: currentUser.role
})
});
const { url } = await r.json();
window.open(adaptUrl(url), '_blank');
}
Step 2: The tool mints a short-lived token and returns a redirect URL
// Tool backend — sso-url endpoint
router.post('/sso-hub', (req, res) => {
const apiKey = req.headers['x-api-key'];
if (apiKey !== process.env.NEXUS_API_KEY) {
return res.status(401).json({ ok: false });
}
const { user, role } = req.body;
const ssoToken = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + 30_000; // 30 seconds
// Store temporarily — single use
ssoTokens.set(ssoToken, { user, role, expiresAt });
const publicUrl = process.env.NEXUS_PUBLIC_URL;
res.json({
ok: true,
url: `${publicUrl}?sso=${ssoToken}`
});
});
Step 3: The tool's frontend exchanges the SSO token for a session
// Tool frontend — App.jsx
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const ssoToken = params.get('sso');
if (ssoToken) {
fetch('/api/auth/sso-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: ssoToken })
})
.then(r => r.json())
.then(data => {
if (data.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', data.user);
// Remove ?sso= from URL
window.history.replaceState({}, '', '/');
setAuthenticated(true);
}
});
}
}, []);
Step 4: The tool validates the SSO token and issues its own session token
// Tool backend — sso-login endpoint
router.post('/sso-login', (req, res) => {
const { token } = req.body;
const ssoData = ssoTokens.get(token);
if (!ssoData || Date.now() > ssoData.expiresAt) {
ssoTokens.delete(token);
return res.status(401).json({ ok: false, error: 'Invalid or expired SSO token' });
}
// Single use — delete immediately
ssoTokens.delete(token);
// Issue a normal session token
const sessionToken = crypto.randomBytes(32).toString('hex');
sessions.set(sessionToken, {
username: ssoData.user,
role: ssoData.role
});
res.json({ ok: true, token: sessionToken, user: ssoData.user });
});
Total implementation: ~100 lines across Hub and each tool. No dependencies beyond what you already have.
What they don't tell you: the parts that actually bit me
Here's where the tutorials end and reality begins.
1. The token persistence problem
My first implementation stored SSO tokens in memory. Which is fine — until you rebuild the container. Every rebuild wiped the session store. Users got 401s on every restart.
The fix: store sessions in a file (store.json) that persists via Docker volume. Simple, but easy to miss if you're not thinking about container lifecycle from the start.
Also: the data directory path matters. My store.js used path.join(__dirname, '../../data'). With the Docker WORKDIR at /app and store.js at /app/src/store.js, __dirname was /app/src — so ../../data resolved to /data at the container root, not /app/data where the volume was mounted. Data was being written to the ephemeral layer and lost on every rebuild. One character difference in a path, weeks of confusion.
2. The URL hostname problem
The Hub stores each tool's publicUrl when it registers. In a local test environment, that's http://localhost:9091. When you access Hub from another machine on the network, the "Open" button generates a URL pointing to localhost on the remote server — which your browser resolves as your own machine.
The fix is simpler than you'd think: replace the hostname in the URL with window.location.hostname on the client side:
function adaptUrl(rawUrl) {
try {
const u = new URL(rawUrl);
u.hostname = window.location.hostname;
return u.toString();
} catch { return rawUrl; }
}
Preserves the port (which is tool-specific and fixed), replaces only the hostname with whatever the browser used to reach the Hub. Works on any network without configuration.
3. The API key bypass problem
Hub authenticates to each tool using a shared API key (X-Api-Key header). But each tool's auth middleware only knew how to validate JWT session tokens — not API keys. So Hub's proxy requests were getting 401s even though the key was correct.
Fix: add an API key check before the session lookup:
// In each tool's auth middleware
const apiKey = process.env.NEXUS_API_KEY;
if (apiKey && token === apiKey) {
req.user = { username: 'nexus-hub', role: 'admin' };
return next();
}
// Fall through to session lookup
This is obvious in retrospect. It wasn't obvious at 11pm debugging why the metrics panel was empty.
4. The SSO token expiry window
I initially set SSO tokens to expire after 5 minutes. That felt generous. Then I realized: the token is generated the moment the user clicks "Open", but only consumed when the new tab finishes loading and makes the exchange request. On a slow network or a cold Docker container starting up, 30 seconds is already tight. 5 minutes was fine.
But there's a subtler issue: the token must be single-use. If the user opens the same tool twice quickly (double-click, or clicking while the tab is loading), the second request will fail with an expired/used token. I handle this gracefully by falling back to the standard login page instead of showing an error.
Is it worth it?
For my use case: absolutely.
The experience is exactly what I wanted. You log into Hub once. Every tool opens already authenticated. No per-tool login screens, no password managers juggling six different credentials, no session drift.
The implementation is small enough that I understand every line. When something breaks, I know where to look. The security model is simple enough to reason about: shared API key for service-to-service, short-lived single-use tokens for user SSO, standard JWTs for ongoing sessions.
What I gave up: everything Auth0 handles that I didn't mention. Token revocation across sessions. Refresh token rotation. Enterprise SAML federation. Multi-tenant isolation. If you need any of those, build on an existing solution.
What I kept: simplicity, full control, zero external dependencies, and a system I can debug at 2am without reading someone else's documentation.
The honest verdict
Building your own SSO is not scary. The concept is simple. The implementation is small. The hard parts are the operational details that nobody writes about — volume mounts, URL adaptation, middleware ordering, token lifecycle.
Those aren't SSO problems. They're distributed systems problems. And the only way to learn them is to hit them.
NEXUS Ecosystem is open source. The full SSO implementation is in the Hub and each tool's backend.
- GitHub: github.com/Alvarito1983
- Docker Hub: hub.docker.com/u/afraguas1983
Top comments (0)