Open to full-time roles, freelance projects, or just a good conversation about tech. Drop me a line and let's talk.

Auth0 costs $240/month at 1,000 MAU. Okta costs more. And both lock you into their ecosystem. So we built our own — a fully self-hosted, multi-tenant SSO platform with OAuth2/OIDC, magic links, device flow, RBAC, API keys, and request signing. In Go. Deployed on Docker Swarm with 3 replicas per service.
This is the complete technical breakdown.

Before diving into implementation, the honest question: should you?
| Auth0 / Okta | Self-Hosted SSO | |
|---|---|---|
| Cost at 10K MAU | $240–$800/month | Server cost (~$20/month) |
| Setup time | Hours | Weeks |
| Compliance (data residency) | Depends on plan | Full control |
| Custom auth flows | Limited | Unlimited |
| Vendor lock-in | High | None |
| Maintenance burden | None | On you |
If you're building a B2B SaaS product, need data residency compliance, or want full control over auth flows — self-hosted makes sense. If you're a solo developer shipping fast, just use Auth0.
We're building a B2B platform where tenants need isolated identity namespaces, custom branding, and compliance with data residency requirements. Self-hosted was the only real option.
The platform is built as a microservices architecture — 12 Go services behind a central API Gateway, communicating over internal Docker networks.

Each service owns a specific domain and can be scaled, deployed, and updated independently. Auth and OIDC typically see the highest traffic — they can be scaled to 5+ replicas without touching the others.
More importantly: all services are stateless. Every piece of mutable state lives in Postgres (persistent) or Redis (ephemeral/session). This is what makes horizontal scaling safe — any replica can handle any request.
The front door. Every external request passes through here.
Responsibilities:
X-RateLimit-* response headersHandles all authentication flows.
Global user management.
Users are global — they exist independently of tenants. A user joins a tenant via tenant_members. This means one account can belong to multiple organizations, like GitHub.
Multi-tenancy management.
Each tenant is an isolated identity namespace with its own:
acme.sso.example.com)tak_ prefix) and signing secret (tsk_ prefix)OAuth2 client management.
Tenants register OAuth2 clients (web apps, mobile apps, CLIs) with:
authorization_code, refresh_token, device_code)Role-based access control.
Privacy policy versioning and user consent.
Server-to-server authentication.
sk_ prefixed keys, SHA-256 hashed in DBDevice authorization flow (RFC 8628).
For CLI tools, Smart TVs, and any device without a browser. User approves from their phone/computer while the device polls for approval.
Audit trail and login history.
Dashboard API for platform operators.
OpenID Connect provider (RFC 6749 + OIDC Core).
Full OIDC-compliant provider with discovery document, PKCE, token introspection, and revocation.
This is the most unique part of the platform. Every API request must be cryptographically signed before the Gateway will forward it upstream.

Standard JWT auth protects who makes a request, but not what they're sending. A man-in-the-middle could capture a valid JWT and replay a different request with it.
XSI-SSO signs the request body + metadata with a tenant secret that never travels over the wire. This provides:
message = request_id + "." + raw_body + "." + unix_timestamp
signature = hex( HMAC-SHA256( message, signing_secret ) )
Where:
request_id — UUID v4, unique per requestraw_body — exact request body string ("" for GET/DELETE)unix_timestamp — seconds since epochsigning_secret — tenant's tsk_ secret, stored server-side only, never in responses| Header | Description |
|---|---|
XSI-SSO-Timestamp | Unix timestamp (seconds) |
XSI-SSO-Request-ID | UUID v4, unique per request |
XSI-SSO-Signature | HMAC-SHA256 hex signature |
XSI-SSO-Tenant-Auth-Key | Public tenant auth key (tak_ prefix) |
XSI-SSO-Tenant-ID | Optional — narrows tenant lookup |
XSI-Client-ID | Optional — OAuth2 client ID alternative |
async function signRequest(body, signingSecret, authKey) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const requestId = crypto.randomUUID();
const rawBody = body ? JSON.stringify(body) : "";
const message = `${requestId}.${rawBody}.${timestamp}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(signingSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signatureBytes = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(message)
);
const signature = Array.from(new Uint8Array(signatureBytes))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
return {
"XSI-SSO-Timestamp": timestamp,
"XSI-SSO-Request-ID": requestId,
"XSI-SSO-Signature": signature,
"XSI-SSO-Tenant-Auth-Key": authKey,
"Content-Type": "application/json",
};
}
// Usage
const headers = await signRequest({ email: "[email protected]" }, signingSecret, authKey);
const response = await fetch("https://sso.example.com/api/auth/login", {
method: "POST",
headers,
body: JSON.stringify({ email: "[email protected]", password: "..." }),
});import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/google/uuid"
)
func SignRequest(body, signingSecret, authKey string) map[string]string {
timestamp := fmt.Sprintf("%d", time.Now().Unix())
requestID := uuid.New().String()
message := requestID + "." + body + "." + timestamp
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(message))
signature := hex.EncodeToString(mac.Sum(nil))
return map[string]string{
"XSI-SSO-Timestamp": timestamp,
"XSI-SSO-Request-ID": requestID,
"XSI-SSO-Signature": signature,
"XSI-SSO-Tenant-Auth-Key": authKey,
"Content-Type": "application/json",
}
}Some paths bypass signing — email verification links, password reset, and all OIDC routes (which follow their own RFC-defined auth):
GET /api/auth/verify-email
GET /api/auth/magic-link/verify
POST /api/auth/forgot-password
POST /api/auth/reset-password
POST /api/api-keys/verify
/authorize, /token, /userinfo, /jwks.json, /.well-known/*

The most important design decision: users are global, not per-tenant.
-- Users exist independently of tenants
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
password TEXT, -- argon2id hash
created_at TIMESTAMPTZ
);
-- Users join tenants via this table
CREATE TABLE tenant_members (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
roles TEXT[],
joined_at TIMESTAMPTZ,
UNIQUE(tenant_id, user_id)
);This means:
Each tenant has:
auth_key (tak_ prefix) — public identifier sent in request headerssigning_secret (tsk_ prefix) — private key for HMAC signing, never returned in API responsesallowed_login_roles — restrict which roles can log in via which clientsWhen a signed request arrives at the Gateway:
XSI-SSO-Tenant-Auth-Key from headersigning_secret (server-side only)tenant_id to request contextPOST /api/auth/login
{
"tenant_id": "...",
"email": "[email protected]",
"password": "..."
}
→ {
"access_token": "eyJ...", // JWT RS256, 15 min TTL
"refresh_token": "...", // opaque token, 7 day sliding TTL
"session_id": "...",
"user": { "id", "email", "name", "roles", "permissions" }
}
The SSO session cookie (sso_session) is also set on the configured COOKIE_DOMAIN — enabling cross-app SSO for all subdomains.

No password needed. User gets a one-time login link via email.
POST /api/auth/magic-link
{ "tenant_id": "...", "email": "[email protected]" }
→ Email sent with: https://sso.example.com/api/auth/magic-link/verify?token=...
GET /api/auth/magic-link/verify?token=...&tenant_id=...
→ Returns access_token + refresh_token (or redirects if redirect_uri was set)
The standard OIDC flow for web and mobile apps. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks — mandatory for public clients (SPAs, mobile).

Step 1 — Generate PKCE (client-side)
const codeVerifier = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
const codeChallenge = btoa(
String.fromCharCode(...new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))
))
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');Step 2 — Redirect to /authorize
GET /authorize?
client_id=...&
redirect_uri=https://myapp.com/callback&
response_type=code&
scope=openid+profile+email&
state=random_state&
code_challenge=BASE64URL(SHA256(verifier))&
code_challenge_method=S256
Step 3 — User authenticates (POST /authorize)
{
"client_id": "...",
"redirect_uri": "https://myapp.com/callback",
"response_type": "code",
"scope": "openid profile email",
"state": "...",
"code_challenge": "...",
"code_challenge_method": "S256",
"tenant_id": "...",
"email": "[email protected]",
"password": "..."
}Step 4 — Exchange code for tokens
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
client_id=...&
client_secret=...&
redirect_uri=https://myapp.com/callback&
code_verifier=ORIGINAL_VERIFIER
Step 5 — Get user info
GET /userinfo
Authorization: Bearer ACCESS_TOKEN
→ {
"sub": "user-uuid",
"email": "[email protected]",
"name": "John Doe",
"tenant_id": "...",
"roles": ["admin"],
"permissions": ["users.read", "users.write"]
}
For CLI tools, Smart TVs, game consoles — any device without a convenient browser.
An illustration of the device flow authentication: a TV screen on the left showing "Visit sso.example.com/device — Enter code: ABCD-1234". A smartphone on the right showing the SSO approval screen with an "Approve" button. An arrow connecting them labeled "No direct connection needed". Dark background, modern flat design.
This is how GitHub CLI, Google TV, and Xbox do authentication. The device gets a short code, the user approves on their phone, and the device gets tokens.
// 1. Device requests code
POST /device/code
{ "tenant_id": "...", "client_id": "..." }
→ {
"device_code": "long_opaque_code",
"user_code": "ABCD-1234",
"verification_uri": "https://sso.example.com/device",
"expires_in": 900,
"interval": 5
}
// 2. Show user_code to user:
// "Visit https://sso.example.com/device and enter: ABCD-1234"
// 3. User approves on their browser (already logged in)
POST /device/authorize
Authorization: Bearer USER_ACCESS_TOKEN
{ "user_code": "ABCD-1234", "approve": true }
// 4. Device polls every 5 seconds
GET /device/status?device_code=long_opaque_code
→ { "status": "authorization_pending" } // not yet
→ { "status": "approved" } // ready!
→ { "status": "denied" } // user rejected
// 5. Device exchanges code for tokens
POST /device/token
{ "device_code": "long_opaque_code", "client_id": "..." }
→ { "access_token": "...", "refresh_token": "...", "device_id": "..." }
POST /api/auth/refresh
{ "refresh_token": "current_refresh_token" }
→ {
"access_token": "new_jwt",
"refresh_token": "new_refresh_token" // old one is revoked
}
Token rotation means each refresh invalidates the old token and issues a new one. If a refresh token is stolen and used, the legitimate user's next refresh will fail — alerting them to a breach.
| Token | TTL | Behavior |
|---|---|---|
| Access Token (JWT) | 15 minutes | Short-lived, stateless |
| Refresh Token | 7 days sliding | Resets on each use |
| SSO Session Cookie | 7 days sliding | Mirrors refresh token |
| Magic Link | 15 minutes | One-time use |
| Absolute Session Max | 90 days | Hard limit from first login |
Once logged in, users don't re-authenticate on other apps sharing the same COOKIE_DOMAIN.
App A:
User visits → /authorize → Logs in → sso_session cookie set on .example.com
→ Redirected back with tokens ✓
App B (same domain, same tenant):
User visits → /authorize → Cookie detected → Auth code auto-issued
→ Redirected back with tokens ✓ (no login form shown)
Set COOKIE_DOMAIN=.example.com and all apps on *.example.com share the session.

Tenant
└── Roles (tenant-scoped)
└── Permissions (global definitions)
e.g. users.read, users.write, tenants.read, audit.read
User
└── TenantMember
└── Assigned Roles (per tenant)
users.read users.write users.delete
tenants.read tenants.write tenants.delete
clients.read clients.write
roles.read roles.write
audit.read
policies.read
// 1. Create a role
POST /api/rbac/roles
{ "tenant_id": "...", "name": "editor", "description": "Can manage content" }
// 2. List available permissions
GET /api/rbac/permissions
// 3. Assign permissions to role
POST /api/rbac/roles/{role_id}/permissions
{ "permission_ids": ["uuid-of-users.read", "uuid-of-users.write"] }
// 4. Assign role to user
POST /api/rbac/users/{user_id}/roles
{ "role_ids": ["uuid-of-editor-role"] }
Roles and permissions are embedded in the JWT claims — no database lookup needed on each request:
{
"sub": "user-uuid",
"tenant_id": "tenant-uuid",
"roles": ["editor", "viewer"],
"permissions": ["users.read", "users.write"],
"exp": 1234567890
}For server-to-server integrations. No browser, no user, just a service calling another service.
// Create
POST /api/api-keys
{ "tenant_id": "...", "name": "Backend Service", "scopes": ["users.read"], "expires_in_days": 365 }
→ { "key": "sk_..." } // shown ONCE — store it securely
// Use
GET /api/users
X-API-Key: sk_...
X-Tenant-ID: ...
// Verify (internal service-to-service)
POST /api/api-keys/verify
{ "key": "sk_..." }
→ { "valid": true, "tenant_id": "...", "scopes": ["users.read"] }
Keys are stored as SHA-256 hashes — even if the database is compromised, raw keys cannot be recovered.
All JWTs are signed with RS256 (RSA + SHA-256) — asymmetric signing with a 2048-bit private key.

Why asymmetric over HS256 (symmetric)?
/jwks.json) lets any service or third party discover and verify tokens without calling the auth service# Generate keys (store private key securely — in Docker secrets in production)
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pemAny service can discover the public key and verify tokens without calling the SSO platform:
GET /.well-known/openid-configuration
→ { "jwks_uri": "https://sso.example.com/jwks.json", ... }
GET /jwks.json
→ { "keys": [{ "kty": "RSA", "use": "sig", "kid": "...", "n": "...", "e": "..." }] }
The platform is a full OpenID Connect Provider, RFC 6749 compliant.
GET /.well-known/openid-configuration // Discovery document
GET /authorize // Authorization endpoint
POST /authorize // Authorization (form post)
POST /token // Token endpoint
GET /userinfo // UserInfo endpoint (Bearer required)
GET /jwks.json // JSON Web Key Set
POST /introspect // Token introspection (RFC 7662)
POST /revoke // Token revocation (RFC 7009)
{
"issuer": "https://sso.example.com",
"authorization_endpoint": "https://sso.example.com/authorize",
"token_endpoint": "https://sso.example.com/token",
"userinfo_endpoint": "https://sso.example.com/userinfo",
"jwks_uri": "https://sso.example.com/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"code_challenge_methods_supported": ["S256"]
}This means any OIDC-compatible library can integrate with the platform out of the box — NextAuth.js, Passport.js, Spring Security, etc.
Argon2id — the winner of the 2015 Password Hashing Competition. Memory-hard, resists GPU/ASIC attacks.
Every XSI-SSO-Request-ID is stored in Redis with a 5-minute TTL. A second request with the same ID within 5 minutes is rejected — even if the signature is valid.
Signature and password comparisons use constant-time comparison (hmac.Equal, subtle.ConstantTimeCompare) — prevents timing-based secret extraction.
OAuth2 client secrets are SHA-256 hashed in the database. Plaintext is shown once at creation and never stored.
signing_secret (tsk_ keys) are:
auth_key (which is public)Redis sliding window rate limiter on the Gateway. Standard response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1234567890
Even with active refresh tokens, sessions expire 90 days from the original login. Users are forced to re-authenticate — prevents indefinitely-lived sessions from compromised tokens.
Key design decisions worth noting:
-- Users are global (no tenant_id)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
name TEXT,
password_hash TEXT, -- argon2id
email_verified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tenant membership is the join table
CREATE TABLE tenant_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(tenant_id, user_id)
);
-- Client secrets are hashed
CREATE TABLE clients (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT,
client_secret_hash TEXT, -- SHA-256, never plaintext
redirect_uris TEXT[],
grant_types TEXT[],
scopes TEXT[]
);
-- Signing secret never in API responses
CREATE TABLE tenants (
id UUID PRIMARY KEY,
name TEXT,
slug TEXT UNIQUE,
auth_key TEXT UNIQUE, -- tak_ prefix, public
signing_secret TEXT UNIQUE -- tsk_ prefix, server-side only
);
// TODO
The platform runs on Docker Swarm with 3 replicas per service:
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
order: start-first # zero-downtime rolling updates
resources:
limits:
cpus: '0.50'
memory: 256MInternet → Cloudflare → Nginx
│ sso_network (overlay, attachable)
Gateway (3 replicas)
│ sso-internal (overlay, internal)
All Services (3 replicas each)
│
Postgres + Redis (1 replica, pinned to manager)
sso_network — attachable overlay, allows Nginx (standalone container) to reach Gatewaysso-internal — internal overlay, no external access, service-to-service onlyJWT keys are managed as Docker Swarm secrets — encrypted at rest, never written to disk on worker nodes:
printf '%s' "$(cat keys/private.pem)" | docker secret create jwt_private_key -
printf '%s' "$(cat keys/public.pem)" | docker secret create jwt_public_key -1. Get your tenant credentials after setup:
auth_key: tak_xxxxxxxxxxxx (public, send in headers)
signing_secret: tsk_xxxxxxxxxxxx (private, never expose)
2. Sign and send a login request:
const headers = await signRequest(
{ tenant_id: TENANT_ID, email: "[email protected]", password: "..." },
SIGNING_SECRET,
AUTH_KEY
);
const res = await fetch("https://sso.example.com/api/auth/login", {
method: "POST",
headers,
body: JSON.stringify({ tenant_id: TENANT_ID, email: "[email protected]", password: "..." }),
});
const { access_token, refresh_token } = await res.json();3. Use the access token:
// Subsequent API calls only need Bearer auth (XSI signing still required)
const headers = await signRequest("", SIGNING_SECRET, AUTH_KEY);
const res = await fetch("https://sso.example.com/api/auth/me", {
headers: { ...headers, Authorization: `Bearer ${access_token}` },
});// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth";
export default NextAuth({
providers: [
{
id: "sso-platform",
name: "SSO Platform",
type: "oauth",
wellKnown: "https://sso.example.com/.well-known/openid-configuration",
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
authorization: { params: { scope: "openid profile email" } },
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
};
},
},
],
});Building a full SSO platform from scratch taught us a few things:
1. Statelessness is non-negotiable for scaling. Every design decision that puts state in application memory (in-memory rate limits, local token caches) breaks horizontal scaling. Redis is your best friend.
2. The signing protocol is worth the complexity. Standard JWT auth is enough for most apps. But for a platform that other apps depend on, request signing adds a meaningful layer of protection against replay and MITM attacks with minimal client-side overhead.
3. Global users with tenant membership > per-tenant users. The alternative — storing users inside each tenant — creates a nightmare when users belong to multiple organizations. The join table pattern scales cleanly.
4. Ship OIDC from day one. We almost skipped OIDC and just did custom JWT. The RFC is dense and the implementation takes time. But OIDC compatibility means every existing OAuth2 library in every language works with your platform out of the box. The payoff is massive.
5. Microservices complexity is real. 12 services means 12 Dockerfiles, 12 deployment units, 12 log streams. The operational overhead is significant. For a platform that will outlive the initial team and grow to serve multiple products, it's worth it. For a weekend project — probably not.
auth.your-company.com)The full source code and deployment guide are available on GitHub. If you're building a B2B SaaS product and need a solid auth foundation without the Auth0 bill — this is a good starting point.