← Back to blog

May 25, 2026 · 3 min read

Building Knowbase, Part 1: Auth, Orgs, and the Multi-Tenant Foundation

KnowbaseNestJSPostgreSQLAuth

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:

  1. Organisation membershipowner | admin | member
  2. Workspace membershipowner | 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:

  1. GET /auth/google redirects to Google
  2. Callback creates or finds the user, then issues two tokens as httpOnly cookies:
    • 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.