May 25, 2026 · 3 min read
Building Knowbase, Part 1: Auth, Orgs, and the Multi-Tenant Foundation
Every project has a phase where you're not building features — you're building the ground the features will stand on. For Knowbase, that was the first few weeks: getting auth right, designing the tenancy model, and making sure the database schema wouldn't need a painful rewrite the moment real users showed up.
What Knowbase is
Knowbase is a multi-tenant knowledge base SaaS. Users create Organisations, inside which they create Workspaces, inside which they store and search Documents. The mental model is deliberately close to Notion or Confluence — a company is an org, a team is a workspace, their content is documents.
The stack I landed on:
- API: NestJS v11 + Drizzle ORM + PostgreSQL (hosted on Supabase)
- App: Next.js 16, React 19, Tailwind v4, shadcn/ui
Designing the tenancy model
The hardest decision upfront wasn't technical — it was how granular to make the roles. I settled on two separate membership layers:
- Organisation membership —
owner | admin | member - Workspace membership —
owner | admin | editor | viewer
The key insight: a workspace member doesn't link directly to a user. It links to an organisation_members row. This means you can't be in a workspace without being in the org first, which makes access checks a lot cleaner.
// organisation_members → workspace_members (not users → workspace_members)
workspaceMembers: {
workspaceId: uuid,
organisationMemberId: uuid, // FK to organisation_members.id
role: workspaceRole,
}This indirection pays off constantly. When you revoke someone from an org, they lose workspace access automatically.
Google OAuth + JWT cookies
I went with Google OAuth only — no email/password. The flow is:
GET /auth/googleredirects to Google- Callback creates or finds the user, then issues two tokens as
httpOnlycookies:- Access token — 15 minute lifetime, JWT
- Refresh token — 7 day lifetime, bcrypt-hashed in the DB
// Refresh token is never stored in plaintext
const hash = await bcrypt.hash(refreshToken, 10);
await db
.update(users)
.set({ refreshTokenHash: hash })
.where(eq(users.id, userId));Storing only the hash means a leaked DB dump can't be used to forge sessions. Rotation happens on every refresh — old token is invalidated the moment a new one is issued.
The org slug problem
Org slugs are user-picked and globally unique — they appear in URLs like /organisation/acme-corp. Workspace slugs are auto-generated: kebab-name-{8-char-uuid}, so there's no collision risk and users never have to think about them.
I almost made workspace slugs user-picked too, but the UX overhead wasn't worth it. Users care about workspace names, not URLs.
The 3-org limit
One early product decision: cap owned orgs at 3 per user. It's enforced at the service layer before insert:
const owned = await db
.select()
.from(organisations)
.innerJoin(orgMembers, eq(orgMembers.organisationId, organisations.id))
.where(and(eq(orgMembers.userId, userId), eq(orgMembers.role, "owner")));
if (owned.length >= 3) {
throw new ForbiddenException("Maximum 3 owned organisations allowed");
}A simple guard, but it shapes the product. Knowbase isn't a personal tool — it's meant for teams. Three orgs is plenty for someone managing multiple companies; if you need more, that's a pricing conversation.
What came next
With auth and tenancy in place, the boring-but-essential foundation was done. No real user-facing features yet — just a working login, a way to create orgs and workspaces, and a schema I was reasonably confident in.
The next phase was about making Knowbase actually useful: documents.