Most auth tutorials assume you have one provider and one app. KaryoSpace needed to support three authentication modes simultaneously: Okta OIDC for enterprise customers, generic OIDC for orgs running their own identity provider, and local username/password with TOTP for orgs with no SSO at all. All three had to coexist, with org-level data isolation enforced regardless of which mode was used.
The original implementation hardcoded Okta endpoints. The first self-hosted customer who tried to use Keycloak showed us the problem immediately. The fix was to replace every Okta-specific URL with environment variables that point to any OIDC-compliant provider.
type OIDCConfig struct {
ClientID string // OIDC_CLIENT_ID
ClientSecret string // OIDC_CLIENT_SECRET
IssuerURL string // OIDC_ISSUER (e.g. https://accounts.google.com)
RedirectURI string // OIDC_REDIRECT_URI
}
func discoverEndpoints(issuerURL string) (*OIDCEndpoints, error) {
// RFC 8414: GET /.well-known/openid-configuration
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
if err != nil { return nil, err }
var cfg OIDCEndpoints
json.NewDecoder(resp.Body).Decode(&cfg)
return &cfg, nil
}
Endpoint discovery via /.well-known/openid-configuration means the Go code does not need provider-specific URL patterns. Any OIDC provider that publishes a discovery document works: Okta, Google, Keycloak, Auth0, Authentik, Dex.
For orgs that cannot run an identity provider at all, local auth stores a bcrypt-hashed password in MongoDB alongside the user record. An admin creates users via API; the admin communicates the initial password out of band; the user can change it on first login.
type LocalCredential struct {
UserID string `bson:"user_id"`
OrgID string `bson:"org_id"`
PasswordHash string `bson:"password_hash"` // bcrypt cost 12
TOTPSecret string `bson:"totp_secret,omitempty"`
TOTPEnabled bool `bson:"totp_enabled"`
CreatedAt time.Time `bson:"created_at"`
}
func createLocalUser(email, password, name, orgID string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil { return err }
// create employee record + local_credentials document atomically
// ...
}
bcrypt cost 12 is the right choice for 2026: slow enough to make offline attacks expensive, fast enough that login does not feel sluggish on the free-tier ARM VM. At cost 12, a single bcrypt on the Oracle A1 VM takes about 400ms.
TOTP implementation follows RFC 6238. The server generates a 20-byte random secret per user, encodes it as base32, and returns a QR code URL the user scans with their authenticator app. Verification uses HMAC-SHA1 with a 30-second time step and a one-step window (accepts the previous and next code to handle clock skew).
func generateTOTP(secret string, t time.Time) string {
key, _ := base32.StdEncoding.DecodeString(secret)
counter := t.Unix() / 30
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(counter))
mac := hmac.New(sha1.New, key)
mac.Write(buf)
h := mac.Sum(nil)
offset := h[len(h)-1] & 0x0f
code := binary.BigEndian.Uint32(h[offset:offset+4]) & 0x7fffffff
return fmt.Sprintf("%06d", code%1000000)
}
func verifyTOTP(secret, provided string) bool {
now := time.Now()
for _, delta := range []time.Duration{-30 * time.Second, 0, 30 * time.Second} {
if generateTOTP(secret, now.Add(delta)) == provided {
return true
}
}
return false
}
KaryoSpace is multi-tenant. Every authenticated user belongs to an org, and all their data is scoped to that org. The org ID is derived from the user's email domain. A user logging in as alice@acmecorp.com gets org_id = "acmecorp.com". Every MongoDB query in the app includes this org ID as a filter.
func extractOrgID(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 { return "" }
return strings.ToLower(parts[1])
}
// Every handler that reads user data calls this
func requireSameOrg(userOrgID, resourceOrgID string) bool {
return userOrgID != "" && userOrgID == resourceOrgID
}
This approach means org isolation is structurally enforced at the data model level, not just in application logic. A GitHub Actions CI check runs on every push and does static analysis on every MongoDB query, verifying that org_id appears in the filter. That check was added after Claude flagged a theoretical path where a newly added handler could bypass the org check. It has caught three regressions since.
Sessions use Gorilla sessions with a 32-byte signing key and a 32-byte encryption key stored in the environment. The session cookie is HttpOnly, Secure, and SameSite=Lax. An idle timeout of 8 hours is enforced by storing last_active in the session and checking it on every authenticated request.
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, _ := store.Get(r, "auth-session")
userID, _ := sess.Values["user_id"].(string)
if userID == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
lastActive, _ := sess.Values["last_active"].(int64)
if time.Since(time.Unix(lastActive, 0)) > 8*time.Hour {
sess.Options.MaxAge = -1
sess.Save(r, w)
http.Redirect(w, r, "/login?reason=timeout", http.StatusFound)
return
}
sess.Values["last_active"] = time.Now().Unix()
sess.Save(r, w)
next(w, r)
}
}
Login endpoints get a per-IP sliding window rate limiter: 10 attempts per minute, with a 15-minute lockout after 5 consecutive failures. The window is implemented in-memory with a cleanup goroutine that purges stale entries every 5 minutes.
All write endpoints get a per-user rate limiter: configurable per endpoint type. AI query submission is capped at 30 per minute per user. Email send is capped at 10 per minute. The per-user limiter uses the user ID from the session, not the IP, so it is not bypassable by IP rotation.
Running Okta locally during development is painful. KaryoSpace has a /dev/login?as=email endpoint that sets a session directly, skipping all auth flows. It is guarded by two conditions that both must be true: APP_ENV != "production" AND DEV_LOGIN=true. Neither condition alone is sufficient. The production environment never sets DEV_LOGIN, and the dev environment never sets APP_ENV=production. The endpoint simply does not exist in production.
Questions about KaryoSpace or this article? We'd love to hear from you.