# The cost of the first login

> The login screen looks like the simplest thing in the product. It is the one thing every user goes through before seeing any value, and the list of what it requires does not fit in an afternoon.

When someone estimates a new SaaS, login is usually the first item on the list and the cheapest one. Two text fields and a button. I have heard that estimate said out loud many times, including by me.

The problem is not difficulty. It is the length of the list.

## The list nobody writes into the estimate

Signup and login are the two screens you pictured. After them comes the confirmation email, and with it the user who never got it, who needs a resend button, which only makes sense if you can tell "wrong password" apart from "account not confirmed yet". Different messages, different paths.

Password recovery is another flow entirely: the screen asking for the email, the link carrying a token, the page that reads that token from the URL, the validation, the new-password screen, and what happens when the link already expired. In CastorStack the recovery token arrives in the URL fragment and is read before anything renders, because a fragment survives redirects badly.

Then there is the session. Where it lives, how long it lasts, what happens when the access token expires while the user is halfway through a filled-in form. Silent refresh or logging them out are product decisions dressed as technical ones.

None of this is hard. Add it all up and you have two weeks, not an afternoon.

## The half with no screen

The frontend only hands over a token. Someone has to believe it.

The CastorStack API validates Supabase JWTs by fetching the public keys from the project's own JWKS endpoint, handling key rotation, with one minute of tolerance for clock drift between machines. Issuer, audience and lifetime are all checked. There is an HS256 path for local development, the kind of shortcut that has to be isolated and documented, because it is exactly what leaks into production when nobody is looking.

That part shows up in no screenshot of the product, and it is the only thing stopping someone from forging a token and signing in as another person.

## Knowing who you are is not knowing what you may do

Authentication answers "who are you". What remains is the question the product actually asks: may you see this page, call this endpoint, download this file.

They are separate axes. The user is authenticated, but the subscription lapsed yesterday. Authenticated and paying, but their plan does not include the feature. Authenticated, paying and on the right plan, but you turned the feature off for everyone after an incident.

In CastorStack that is handled with attributes on the endpoints and an access evaluation that reads the plan, the plan's features and a per-feature kill switch. The admin panel is a separate application with a separate deployment, and all of its routes live under their own prefix behind an admin role check. Physical separation is cheap and removes a whole class of mistake.

## The hole Supabase leaves open by default

This one gets its own section because it almost slipped past us here.

Supabase publishes every table in the database through an automatic REST API, reachable with the anonymous key. That key sits in the frontend bundle where anyone can read it. This is by design: the real protection is Postgres Row Level Security, and it does not turn itself on for the tables your API creates through migrations.

The fix was a migration that enables RLS on the fourteen server-managed tables, revokes the public roles' grants, and changes the schema's default privileges so any table created from then on is born closed. That last part is the one that matters: protection has to be the default, not a checklist item somebody forgets in the next migration.

## Errors in three languages

If the product sells outside your country, "invalid password" also has to exist outside your language. API error responses follow RFC 7807 and are translated according to the request's language header, in the same three languages as the rest of the system.

Small detail, but it is the kind nobody goes back to fix. It stays English forever.

## What is not here

There is no two-factor authentication. No account lockout after failed attempts, and no password policy written in this repository: those settings live in the Supabase dashboard, and calling them a template feature would be a lie. Social login is not implemented. There is no listing or revoking of active sessions. There are no organizations or teams, which was a decision rather than an oversight: the scope unit is the profile, and multi-tenancy is the sort of thing you add when a customer asks, not before.

The audit log records what happens in the API, so catalog, payment provider and content changes are stored with the actor, the IP and what changed. Login and logout are not there, because they happen in Supabase and never touch the API.

I would rather have that list written down than discovered during launch week.

## Why this matters more now

Writing the CRUD got cheap. A model produces the login screen in seconds, and it even works.

What it does not produce is the decision about where the refresh token lives, RLS enabled before someone points a REST client at your database, or the memory that resending a confirmation is a flow and not a button. You learn those things once, and it is cheaper to learn them in someone else's code.
