Once my tool could send an email, it started to feel like more than a script.
Then I had another thought:
What if this was not only useful to me? What if other people could log in and use it too?
It sounds like a small step. Add a sign-in button, create a user profile and suddenly I have a Software as a Service product.
Except, of course, I do not.
I have a login page.
This is Part 3 of Building Again: My Journey into Vibe Coding—my personal series about what happens when an AI-assisted prototype starts becoming a real application.
A login is not a SaaS architecture
When I was the only person using the tool, the security model was wonderfully simple.
I was the user.
The data was mine. Every record belonged to me. There was no need to ask which customer a report belonged to, whether I was allowed to update it or what should happen if I copied the identifier of someone else’s record into a URL.
The moment I invited another user in, all of that changed.
Now the application needed to know:
- Who is this person?
- How did they authenticate?
- Which customer or organisation do they belong to?
- Can they belong to more than one?
- What are they allowed to see or change?
- Which data belongs to them?
- What happens when they leave?
- How do I prove that they cannot access somebody else’s data?
That final question is the one that matters most.
If I am building a genuine multi-user SaaS product, a customer is not merely trusting me to make the application work. They are trusting me to ensure that their data does not appear in another customer’s browser, API response, report or export.
That is a rather bigger responsibility than adding a sign-in form.
How should people sign in?
The first visible decision was how users would access the application.
Some people will want to use a Google account. Others will prefer GitHub. Someone with a personal Outlook or Hotmail address may choose their Microsoft account, while a business customer might expect to use their Microsoft Entra ID identity. There is also the familiar option of registering with an email address and password.
Those choices may look similar on a sign-in page, but they are not all the same thing.
A personal Microsoft account is not the same identity system as a customer’s Entra tenant. Signing in with Google is not simply typing a Gmail address into a password field. Enterprise federation introduces different expectations around domains, account lifecycle and access removal.
And if I offer a plain email address and password, I also inherit password storage, verification, reset, recovery and abuse scenarios.
I could attempt to build all of that myself.
I could also build my own SMTP relay, database engine and cloud platform while I am at it.
There is a point at which building everything yourself stops being independence and starts being an unnecessary collection of security liabilities.
Where Clerk came in
This is where a service such as Clerk became useful for me.
Clerk provides the authentication layer and user-management experience. It can support social connections through providers such as Google, Microsoft and GitHub, alongside email-based sign-in options. For business scenarios, it also supports enterprise connections using SAML and OpenID Connect, including Microsoft Entra ID and Google Workspace. Clerk’s social connection guidance explains the consumer OAuth model, while its Enterprise SSO documentation covers the workforce identity model.
That allowed me to avoid reinventing authentication flows and to give users several familiar ways to sign in.
It also made the first version appear deceptively easy.
AI could help me integrate the SDK, add a sign-in component, protect a route and display the current user’s name. A short time later, two different people could create accounts and reach the application.
Technically, it was now multi-user.
Architecturally, I still had work to do.
Clerk could tell my application who had signed in. It could not decide which rows in my database they should be allowed to read.
That distinction is the heart of this post.
Authentication is not authorisation
Authentication answers one question:
Who are you?
Authorisation answers a different one:
What are you allowed to do here?
A valid session proves that the request came from an authenticated user. It does not mean that the user should be able to retrieve any record whose identifier they happen to know.
This is an easy mistake to make when building quickly. The page is protected, the user is signed in and the database request works. Everything looks secure because an unauthenticated visitor cannot open the page.
But what happens when User A changes this:
/reports/report_123
to this?
/reports/report_456
If report_456 belongs to User B and the backend returns it simply because it exists, the application has an authorisation vulnerability. Hiding the link from User A or filtering it out of their dashboard does not protect the underlying resource.
The browser is not the security boundary.
The API and data-access layer have to enforce the decision.
The boundary I designed
I did not want the customer boundary to depend on an email address or on a filter applied by the frontend.
Instead, I separated identity from ownership.
The model has four important parts:
- Clerk authenticates the person.
- My application maps the Clerk identity to an internal user.
- A membership links that user to an internal tenant.
- Every customer-owned record carries that tenant’s internal identifier.
In simplified form, it looks like this:
Clerk user
↓
Application user
↓
Tenant membership and role
↓
Tenant-owned data
The external identity is still important. The clerkUserId provides a stable link between the authenticated session and my application’s user record.
But the application owns its business model.
It has an internal userId, an internal tenantId and a membership that says which user belongs to which tenant and in which role. Audit runs, snapshots, findings, reports and other customer data are all scoped to that tenantId.
That matters because the user and the customer are not necessarily the same thing.
One customer may have several users. A consultant may legitimately belong to several customer tenants. A user may change their email address without changing identity. A company may want to remove a person without deleting the reports that belong to the company.
Using the email address as the ownership boundary would mix all of those concepts together.
Using an explicit tenant and membership model keeps them separate.
Every request has to earn access
For each protected request, the application follows the same basic sequence:
- Validate the Clerk session on the server.
- Retrieve the authenticated Clerk user identifier from that verified session.
- Map it to the internal application user.
- Resolve the active tenant.
- Confirm that the user has a current membership in that tenant.
- Query the requested resource within that tenant boundary.
- Check the user’s role or permission before allowing the action.
The crucial point is that the server derives the user identity from the validated session. It does not trust a userId supplied by the browser.
Likewise, receiving a tenantId or reportId in a URL or request body does not prove that the caller can use it. Those are identifiers, not permissions.
This turns a query that effectively means:
Give me report_456
into one that means:
Give the authenticated user report_456
only if it belongs to a tenant where they hold a valid membership
and their role permits this action
If any part of that chain fails, access is denied.
That is deliberately repetitive. Authorisation is not something I want to remember to add occasionally.
OWASP recommends a deny-by-default approach and validating permissions on every request. It also makes the uncomfortable but important point that an attacker needs to find only one missed check. OWASP Authorization Cheat Sheet
Users, tenants and organisations
For a simple consumer application, each user may own only their own data. In that model, an ownerUserId can be enough to establish the boundary.
A business SaaS product usually becomes more complicated.
The customer is often an organisation rather than an individual. That organisation may have an owner, administrators, standard users, auditors and perhaps an external consultant. Those people may need different permissions while accessing the same customer-owned data.
This is why I designed around tenants and memberships rather than assuming one user equals one customer forever.
Clerk also has an Organizations capability. Users can belong to multiple organisations, and the active organisation, membership, role and permissions can be carried in the session context. That can remove a great deal of undifferentiated work from a B2B application. Clerk’s Organizations documentation describes that model.
But even then, the database query still has to apply the correct organisation or tenant boundary.
The presence of an active organisation in a session is useful context. It is not magic row-level isolation for every table I create.
Session data is context, not the database
Clerk issues a short-lived session token containing information about the user and their session. Its SDK middleware can validate that token for each request. Clerk’s session-token documentation also recommends keeping larger application data in your own database rather than stuffing everything into custom token claims.
That aligns with the separation I wanted.
The session carries enough trusted context to begin an access decision. My database remains authoritative for the application’s users, tenants, memberships, entitlements and customer records.
This also gives me somewhere to manage the lifecycle that exists beyond sign-in:
- Membership invitations and removals
- Application roles
- Subscription or entitlement status
- Customer ownership
- Audit history
- Data-retention decisions
- Offboarding without destroying business records
Authentication is an event. Customer access is a lifecycle.
How I tested the separation
Seeing the correct dashboard while signed in as one user is not evidence of tenant isolation.
I needed at least two deliberately separate test identities and two sets of customer data.
Then I tested the boundary from the less friendly direction:
- Could User A retrieve User B’s record by changing its identifier?
- Could they call the API directly without using the application interface?
- Could they update or delete another tenant’s record?
- Did search, exports and generated reports apply the same tenant filter?
- Did background jobs retain the tenant context?
- Did administrative functions require an explicit role?
- What happened after a membership was removed?
- Did an error message reveal that another customer’s record existed?
The expected result was not an empty screen created by the frontend. It was a denial from the server and no data returned from the protected query.
Where the database platform supports row-level security, that can provide another valuable layer. But it should reinforce a clearly defined ownership model, not compensate for an application that has never decided who owns each record.
Outsourcing identity does not outsource accountability
Using Clerk was a sensible choice because authentication is specialised, security-sensitive work. It allowed me to support familiar sign-in experiences without attempting to become an identity provider myself.
But integrating a good identity service does not make the entire application secure.
I still had to decide:
- Which providers I would trust
- Whether consumer accounts were appropriate
- Whether business customers required enterprise SSO
- How users mapped to customers
- How roles and permissions worked
- How customer data was partitioned
- How access was removed
- How the isolation boundary was tested
That is the recurring lesson from building with AI.
AI can help integrate the SDK and generate the first protected route. It can suggest a database schema and write the query. It can even produce tests.
But it does not own the consequences if Customer A receives Customer B’s report.
I do.
A login makes an application multi-user. A trustworthy SaaS product requires every user’s access—and every customer’s boundary—to be designed deliberately.
And, inevitably, solving this problem introduced the next one.
My application now had a Clerk publishable key, a server-side secret key, webhook signing secrets and OAuth credentials for production identity providers. Add the SMTP credential, database connection and deployment token from the earlier stages, and my small tool had accumulated a growing collection of secrets.
Which raised the next question:
Where do all these secrets go?
Next in the series: Where Do All These Secrets Go?—protecting application credentials, webhook signing secrets, API keys and deployment identities without letting them leak into source code, prompts or pipelines.
Comments
No comments yet — be the first to leave one below.