Authentication
Turn on real sign-in with Clerk, protect sign-up against bots, understand how a request is authenticated from the browser to the database, and how workspaces, roles and permissions work.
At the end of this guide, real people can sign up, create a workspace and invite their teammates. You will also know what happens on every request, from the browser to the server, and where roles and permissions come from.
Neatship doesn't build sign-in itself. It uses Clerk, a hosted sign-in service. Clerk handles accounts, passwords, email codes, social sign-in (Google...), two-step verification, sessions and invitations. Clerk's Organizations are Neatship's workspaces: one workspace per customer organization, with its own members and its own data.
Two modes: mock or Clerk
| Mock (default) | Clerk | |
|---|---|---|
Web app (packages/app-front/.env) | VITE_CLERK_PUBLISHABLE_KEY empty | VITE_CLERK_PUBLISHABLE_KEY=pk_test_... |
Server (packages/app-server/.env) | AUTH_PROVIDER=mock | AUTH_PROVIDER=clerk plus three keys |
| What people see | The app opens directly, signed in as Ada Lovelace, admin of "Acme team" | Clerk's sign-in and sign-up screens, then the app |
The two files must agree. The web app decides its mode from one constant, AUTH_MODE (packages/app-front/src/modules/auth/constants/AuthMode.ts): Clerk when the publishable key is set, mock otherwise.
Set up Clerk
You need a Clerk development instance: Clerk's free test environment, separate from your future production instance.
Create an application at
dashboard.clerk.com. Give it your product name: Clerk shows it on its sign-in screen ("Sign in to …") and in its emails.Choose the sign-in methods: email with password or code, Google, and so on.
Open Organizations. Enable Organizations, turn on Membership required (every person works inside a workspace) and let users create organizations. Keep the default roles, admin and member. Don't add custom permissions: Neatship derives permissions in its own code (see below).
Open the API keys page and copy three values:
In Clerk Looks like Goes in Publishable key pk_test_...packages/app-front/.envasVITE_CLERK_PUBLISHABLE_KEY, andpackages/app-server/.envasCLERK_PUBLISHABLE_KEYSecret key sk_test_...packages/app-server/.envasCLERK_SECRET_KEYJWT public key several lines, starting with -----BEGIN PUBLIC KEY-----packages/app-server/.envasCLERK_JWT_KEY, between double quotesOpen Protect → Rules and check that Bot sign-up protection says Enabled (see "Protect sign-up against bots" below).
In
packages/app-server/.env, setAUTH_PROVIDER=clerk.Stop
yarn startand start it again.
The server part of packages/app-server/.env then looks like this:
AUTH_PROVIDER=clerk
CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_JWT_KEY="-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----"The server checks the shape of the three keys when it starts. A missing or malformed key stops it with a message that names the variable, for example CLERK_SECRET_KEY must start with sk_test_ or sk_live_.
Warning: The secret key gives full access to your Clerk instance. Keep it in .env only. .env files are ignored by git; never paste the key into code or into a committed file.
Now open http://localhost:5180. You land on the sign-in page, with your product name above Clerk's card. Sign up. Clerk then asks you to create a workspace (the page /onboarding/workspace), and the app opens.
Tip: In a development instance, an email address containing +clerk_test (for example someone+clerk_test@example.com) accepts the code 424242, and Clerk sends no email. Handy for creating test accounts quickly.
To go back to the sample user, empty VITE_CLERK_PUBLISHABLE_KEY, set AUTH_PROVIDER=mock, and restart.
Protect sign-up against bots
Scripts that create fake accounts by the thousand are common on any public sign-up page. Clerk stops them with bot sign-up protection: a check by Cloudflare (Turnstile) that runs during sign-up. Real people usually see nothing; a visitor that looks like a bot is asked to tick a box first. Neatship uses Clerk's ready-made sign-up screen, so there is no code to write: you only check one setting, in each Clerk instance (development, then production).
- In the Clerk dashboard, open Protect, then Rules (
dashboard.clerk.com/~/protect/rules). - Find the Bot sign-up protection row and read its Status.
- Enabled: nothing to do.
- Disabled: select Enable, toggle Enable on in the dialog, then select Save.
- On the same page, check two more rules before launch:
- Lockout policy: locks an account after too many wrong passwords (new instances: 10 attempts, then 1 hour). Keep it on.
- Block sign-ups that use disposable email addresses: turn it on, unless your customers really use throwaway addresses.
Clerk documents bot protection as on by default, but older applications and instances may differ: look at the status rather than assume. Clerk's dashboard was reorganized in 2026 (this page used to be called Attack protection); if you don't find it, search the dashboard for "Bot sign-up protection".
- The production web app's security policy already allows Cloudflare's check and Clerk's protection addresses (
packages/app-docker/scripts/write-web-content-security-policy.mjs), so nothing else changes. - Automated browser tests are stopped by this check too.
@clerk/testinggets past it (seedocs/conventions/10-auth-and-billing.md§7). - Don't turn on Clerk's Native API setting unless a mobile app needs it: it opens a way to sign up that skips the bot check.
How sign-in works, end to end
- The browser shows Clerk's screens. The pages
/sign-inand/sign-updisplay Clerk's ready-made components. Your server never sees a password. - Clerk opens a session in the browser. A signed-in person without a workspace is sent to
/onboarding/workspaceto create one or join one they were invited to. - Every API call carries a token. Before each request, the web app asks Clerk for a fresh token and sends it in the
Authorization: Bearer ...header. A token is a short signed message that names the person, their active workspace and their role. It lives 60 seconds; Clerk renews it on its own. The web app never stores it. - The server checks the token without calling Clerk.
RequestContextMiddlewareruns once per request, before anything else. It asks the Clerk driver to verify the token with Clerk's official helper (authenticateRequest), using the instance's public key (CLERK_JWT_KEY). Only tokens issued for your web app's address (FRONTEND_URL) are accepted. - Clerk ids become your own rows on first sight. The first request of a new person creates a
userrow; the first request in a new workspace creates aworkspacerow and aworkspaceMemberrow. Names and emails are read from Clerk's API with the secret key, never from the browser. After this step, the code only handles your own ids (uuids), never Clerk's. - Guards check what the middleware attached. An authentication guard checks that someone is signed in with an active workspace; if not, the answer is
UNAUTHENTICATED. A permission guard checks the member may do this action; if not, the answer isFORBIDDEN. - The resolver does the work. It reads the workspace with
@AuthWorkspace()and calls one service method.
| Step | Code |
|---|---|
| Sending the token | packages/app-front/src/modules/apollo/utils/createAuthLink.ts |
| Checking the token | packages/app-server/src/engine/core-modules/auth/drivers/clerk/clerk-auth.driver.ts |
| Creating your rows | packages/app-server/src/engine/core-modules/auth/drivers/clerk/services/clerk-provisioning.service.ts |
| Attaching them to the request | packages/app-server/src/engine/middlewares/request-context.middleware.ts |
| Guards | packages/app-server/src/engine/guards/ |
Several first requests of a new person can arrive at the same time. A unique index lets one of them create each row; the others read the row it created. Nothing is duplicated.
Workspaces and roles
- Each workspace is a Clerk organization. Its data is isolated from every other workspace (see Security).
- Neatship uses Clerk's two default roles only: admin and member. A role the code doesn't know (for example a custom role added in the Clerk dashboard) gets the member's permissions, the smallest set.
- The token is the source of truth for the role. A role change reaches the API with the next token, within about 60 seconds.
Permissions, derived in code
Permissions are written in Clerk's format (org:<feature>:<action>) but decided by your code, from the role:
| Permission | Admin | Member |
|---|---|---|
org:projects:create | yes | yes |
org:projects:update | yes | yes |
org:projects:delete | yes | no |
org:workspace:manage | yes | no |
org:billing:manage | yes | no |
- The list of permissions:
WORKSPACE_PERMISSIONSinpackages/app-shared/src/constants/WorkspacePermissions.ts. - Who gets what:
WORKSPACE_PERMISSIONS_BY_ROLEinpackages/app-server/src/engine/core-modules/auth/constants/workspace-permissions-by-role.constant.ts.
To add a permission, add it to the first list, then to the second, then rebuild the shared package with npx nx build app-shared --skip-nx-cache. Nothing changes in the Clerk dashboard.
On a server endpoint, the permission is required with a guard:
@Mutation(() => ProjectDto)
@UseGuards(WorkspacePermissionGuard('org:projects:delete'))
async deleteProject(/* ... */) {}In the web app, useHasPermission('org:projects:delete') hides or disables what the member can't do. That is a courtesy only: the server checks the same permission on every request.
Inviting people, changing roles and removing members happen in Clerk's own members screen. Clerk shows those controls to admins only.
Switching workspace
The workspace switcher in the sidebar lists the person's workspaces. Picking one makes it active in Clerk. "Create workspace" opens Clerk's creation dialog. When the active workspace (or the signed-in person) changes, the web app empties its data cache before showing any page, so nothing from the previous workspace stays on screen.
Whose screens are whose
| Screen | Built by |
|---|---|
| Sign-in, sign-up, email code, workspace onboarding | Clerk |
| Workspace switcher and user menu (sidebar) | Neatship, calling Clerk |
| Settings → Profile | Neatship's language setting, then Clerk's account details |
| Settings → Security (Clerk mode only) | Clerk: password, two-step verification, devices, account deletion |
| Settings → Members | Clerk: members, invitations, roles |
| Settings → General | Neatship: the workspace name, saved by the server, which renames the Clerk organization too |
Clerk owns names and emails. People change them in Clerk's profile screen; the web app then asks the server to copy them from Clerk. Clerk's screens follow the app's language (English or French) and its light or dark theme.
The mock mode
The mock lets you try the starter before you create a Clerk account. It is also what the automated API tests use.
- Every request is signed in as Ada Lovelace, admin of "Acme team".
- The header
x-mock-user-email: <seeded email>picks another seeded person.x-mock-user-email: anonymoussimulates a signed-out request. An unknown email counts as signed out. - Names stay editable inside the app. Invitations are shown as "Soon" on the Members page. The Security page doesn't exist.
- The server refuses to start with
AUTH_PROVIDER=mockwhenNODE_ENV=production.
With Clerk, the seeded people simply stay unused: no Clerk account points to them.
Clerk webhooks (optional)
A webhook is a message Clerk sends to your server when something changes on Clerk's side. Without webhooks, the app still works: your copies of names, roles and workspaces update on the next request of the person concerned. Some changes, though, only reach your data through a webhook:
| Change made in Clerk | Without the webhook | With the webhook |
|---|---|---|
| Another member's name or email | when that member next opens the app | at once |
| Workspace renamed in the Clerk dashboard | never | at once |
| Person deleted | never (Clerk refuses their sign-in anyway) | the person and their memberships are soft-deleted |
| Member removed | never (their token no longer names the workspace) | the member is soft-deleted |
| Workspace deleted | never | its Stripe subscription is canceled at once, without refund, then the workspace is soft-deleted |
"Soft-deleted" means the row is marked as deleted and hidden, not erased.
To receive Clerk webhooks on your computer, Clerk must be able to reach it:
- Open a tunnel to your server, for example with
ngrok http 3100. A tunnel gives your local server a temporary public address. - In the Clerk dashboard, open Webhooks and add the endpoint
https://<tunnel address>/webhooks/clerk. - Subscribe to these events:
user.updated,user.deleted,organization.updated,organization.deleted,organizationMembership.created,organizationMembership.updated,organizationMembership.deleted. - Copy the endpoint's signing secret (
whsec_...) intoCLERK_WEBHOOK_SIGNING_SECRETinpackages/app-server/.env, and restart.
Without the signing secret, /webhooks/clerk answers 404. The events are stored and processed by the background worker: see Background jobs and webhooks.
Tip: "Delete workspace" in Settings → General stays disabled on purpose. The deletion reaches your data through the Clerk webhook, so turn the button on only once the webhook is set up in production.
A shared demo account (optional)
To let visitors try your app without creating an account, turn on the shared demo account. The sign-in page then shows Try the demo account: one click, and the visitor is in a workspace with sample projects, with no email and no password.
How it is kept safe:
- No password. The button asks the server for a sign-in token valid for one minute, and Clerk turns it into a session. There is no password to share, change or lock others out with. The server answers 10 such requests per minute per visitor's IP address.
- A member, not an admin. Visitors can create and edit projects, browse the settings and switch language. They can't invite anyone (so no one can send emails to strangers in your product's name), pay, rename or delete the workspace. The account can't create other workspaces or delete itself.
- Reset every night at 04:00 UTC by the background worker: visitors are signed out; names, extra email addresses, phone numbers and photo are removed; other members are removed; the sample projects come back.
Everyone who uses the demo account at the same time shares the same workspace, and sees what the others create until the next reset.
To turn it on (Clerk only):
- Choose an email address nobody uses, for example
demo@<your domain>, and setDEMO_ACCOUNT_EMAIL(inpackages/app-server/.envlocally, inpackages/app-docker/.env.productionin production). Restart the server. - Create the account once:
npx nx run app-server:demo-account:resetlocally, orbash packages/app-utils/prod-compose.sh run --rm demo-account-resetin production. It creates the Clerk user, its workspace (named "Demo workspace", with no admin) and the sample projects. Run it again any time to reset now.
For a public demo of your product, also set VITE_DEMO_BANNER_URL (in packages/app-front/.env, or packages/app-docker/.env.production for the deployed web app) to the page where the product is sold: a banner above every page then says it is a demo, with a "Get <product>" button that opens that page in a new tab. Empty: no banner, as in a real product.
Empty DEMO_ACCOUNT_EMAIL to turn it off: the button disappears. The code is in packages/app-server/src/modules/demo-account/ and packages/app-front/src/modules/demo-account/; the sample projects are in demo-account-sample-projects.constant.ts.
Clerk's free plan
As checked on clerk.com/pricing on 27 Sep 2026 (docs/conventions/10-auth-and-billing.md §8):
- The free plan includes 50,000 monthly users, 100 monthly workspaces, up to 20 members per workspace, the admin and member roles, and custom permissions. The "Secured by Clerk" badge stays on Clerk's screens.
- The Pro plan ($25 per month) removes the badge.
- Unlimited members and custom roles need the B2B add-on ($100 per month).
Prices change: check Clerk's pricing page before you launch.
When something goes wrong
- The server doesn't start after you switched to Clerk. Read the message: it names the key that is missing or malformed.
CLERK_JWT_KEYmust include itsBEGIN PUBLIC KEYline and be wrapped in double quotes. - You are sent back to the sign-in page, or see "Your session has ended". The server and the web app probably disagree: one in mock mode, the other with Clerk. Check both
.envfiles, then restartyarn start. - Sign-in doesn't load. Signing in needs an internet connection to reach Clerk. (Checking tokens doesn't: the server uses the public key.)
- Tokens are refused on a server. Tokens live 60 seconds with 5 seconds of tolerance, so the server's clock must be on time (synced with NTP).
Before going live, you will create a production Clerk instance with pk_live_ and sk_live_ keys, and check its bot protection again (production instances have their own settings). See Deploy and the checklist at the end of Security.