All articles

Auth without lock-in: generic OIDC, local passwords, TOTP, and multi-tenant org isolation in Go

Suman Akkisetty
Founder, KaryoSpace
April 2026
11 min read

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.

Generic OIDC

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.

Local auth: Option A

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 (time-based 2FA)

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
}

Org isolation from email domain

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.

Session management

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)
    }
}

Rate limiting

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.

The dev login bypass

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.

Get in touch

Questions about KaryoSpace or this article? We'd love to hear from you.

Contact Us
Message sent!
We read every submission and will get back to you.