The wrong way to run a multi-tenant username system

If usernames are globally unique across your entire database, two things go wrong the moment you have real customers. First, someone at Company A can't be sara because someone at Company B already is — a coincidence that has nothing to do with either company, yet blocks a perfectly reasonable signup. Second, and worse: a global uniqueness check leaks information across tenants. Trying to register sara and getting "already taken" tells you a sara exists somewhere in the system — which, depending on what else you can infer, can leak whether a specific company or person uses the product at all.

WKFGo's model: usernames are scoped to the organization

A username only has to be unique within its organization, not across the whole platform:

func (s *userService) resolveLoginUser(company, username string) (*userModel.User, error) {
    if strings.TrimSpace(company) == "" {
        return s.userRepo.FindPlatformUserByUsername(username)
    }
    orgID, err := s.userRepo.FindOrgIDBySlugOrName(strings.TrimSpace(company))
    if err != nil || orgID == nil {
        return nil, ErrOrgNotFound
    }
    return s.userRepo.FindByUsernameInOrg(*orgID, username)
}

Two different companies can each have their own sara, their own admin, their own support — the (organization, username) pair is the actual unique key, not username alone. This isn't just a UX nicety; it's what makes multi-tenant signup work without every new customer negotiating over a shared namespace.

Which means login needs to know which company first

If usernames aren't globally unique, "log in as sara" is an ambiguous question until you know which organization's sara. WKFGo resolves this with a company slug baked into the login URL itself: /login/acme_corp. The slug is derived from the company's display name — spaces become underscores, because underscores aren't allowed in company display names, which keeps the separator unambiguous in both directions:

export function slugifyOrgName(name) {
  let s = String(name).trim().toLowerCase();
  s = s.replace(/[^a-z0-9؀-ۿ]+/g, '_');
  s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
  return s;
}

Notice it explicitly keeps Persian characters (؀-ۿ) alongside Latin alphanumerics — a company named in Farsi gets a readable slug in its own script, not a URL mangled into percent-encoded garbage or forced through a Latin-only transliteration.

The one error message allowed to be specific

Login errors are deliberately generic almost everywhere — wrong password, unknown username, and a disabled agent account all collapse into the same "username or password is incorrect" response, so none of those cases can be distinguished by someone probing the login form. But an unrecognized company slug gets its own distinct error:

if errors.Is(err, ErrOrgNotFound) {
    return nil, err
}

This is a deliberate, narrow exception to "always be generic." The company slug is not a secret — it's meant to be shared, bookmarked, and typed into a browser by an employee who might get it slightly wrong (acme-corp instead of acme_corp, or an old company name). Telling that person "no organization found for this login URL" is a straightforward UX fix for a URL typo. Collapsing that into the same generic message as "wrong password" would make a harmless URL mistake indistinguishable from a real credentials problem, which helps nobody — while credentials themselves stay behind the one generic message, because that's the boundary actually worth protecting.

What this means for platform-level accounts

Notice the branch at the top of resolveLoginUser: an empty company string skips the org lookup entirely and looks up a platform user by username — this is the path for SaaS admins and platform-level accounts that aren't scoped to any single tenant's namespace at all. Two separate lookup paths, two separate uniqueness domains: platform accounts in one, each organization's members in their own.

Common mistakes

Making usernames globally unique "for simplicity." It's simpler to build, but it creates artificial signup friction between unrelated customers and leaks cross-tenant existence information through ordinary "taken" errors.

Being generic about company-not-found the same way you're generic about credentials. They're not the same risk — a company slug is public and typo-prone; collapsing it into a security-sensitive generic error just makes routine URL mistakes harder to self-diagnose, for no security benefit.

Transliterating non-Latin company names into ASCII slugs. It produces unreadable URLs for the people who actually need to type them; keeping the original script in the slug (where the character set allows it) is both more correct and more usable.

FAQ

Can I change my company's login slug later?

The slug derives from the organization's display name, so renaming the organization changes the slug — bookmarked old login URLs would need updating.

What happens if two companies pick very similar names that slugify to the same string?

Slug uniqueness is enforced at the organization level; a collision is handled the same way any unique-constraint conflict is — the second organization can't claim an identical slug.

Does this affect API key or MCP authentication?

No — personal API keys (wk_…) already carry organization context baked into the key itself, so they don't need a separate company-slug step the way interactive username/password login does.

Summary

In a multi-tenant product, "unique" almost never means "unique across every customer we'll ever have" — it means unique within the boundary that actually matters, which for a username is the organization, not the platform. Getting that scope right avoids both an artificial namespace fight between unrelated customers and a quiet way to leak who else uses your product.