Skip to content
Authentication

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 emptyVITE_CLERK_PUBLISHABLE_KEY=pk_test_...
Server (packages/app-server/.env)AUTH_PROVIDER=mockAUTH_PROVIDER=clerk plus three keys
What people seeThe 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.

  1. 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.

  2. Choose the sign-in methods: email with password or code, Google, and so on.

  3. 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).

  4. Open the API keys page and copy three values:

    In ClerkLooks likeGoes in
    Publishable keypk_test_...packages/app-front/.env as VITE_CLERK_PUBLISHABLE_KEY, and packages/app-server/.env as CLERK_PUBLISHABLE_KEY
    Secret keysk_test_...packages/app-server/.env as CLERK_SECRET_KEY
    JWT public keyseveral lines, starting with -----BEGIN PUBLIC KEY-----packages/app-server/.env as CLERK_JWT_KEY, between double quotes
  5. Open Protect → Rules and check that Bot sign-up protection says Enabled (see "Protect sign-up against bots" below).

  6. In packages/app-server/.env, set AUTH_PROVIDER=clerk.

  7. Stop yarn start and start it again.

The server part of packages/app-server/.env then looks like this:

env
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).

  1. In the Clerk dashboard, open Protect, then Rules (dashboard.clerk.com/~/protect/rules).
  2. 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.
  3. 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/testing gets past it (see docs/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

  1. The browser shows Clerk's screens. The pages /sign-in and /sign-up display Clerk's ready-made components. Your server never sees a password.
  2. Clerk opens a session in the browser. A signed-in person without a workspace is sent to /onboarding/workspace to create one or join one they were invited to.
  3. 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.
  4. The server checks the token without calling Clerk. RequestContextMiddleware runs 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.
  5. Clerk ids become your own rows on first sight. The first request of a new person creates a user row; the first request in a new workspace creates a workspace row and a workspaceMember row. 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.
  6. 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 is FORBIDDEN.
  7. The resolver does the work. It reads the workspace with @AuthWorkspace() and calls one service method.
StepCode
Sending the tokenpackages/app-front/src/modules/apollo/utils/createAuthLink.ts
Checking the tokenpackages/app-server/src/engine/core-modules/auth/drivers/clerk/clerk-auth.driver.ts
Creating your rowspackages/app-server/src/engine/core-modules/auth/drivers/clerk/services/clerk-provisioning.service.ts
Attaching them to the requestpackages/app-server/src/engine/middlewares/request-context.middleware.ts
Guardspackages/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:

PermissionAdminMember
org:projects:createyesyes
org:projects:updateyesyes
org:projects:deleteyesno
org:workspace:manageyesno
org:billing:manageyesno
  • The list of permissions: WORKSPACE_PERMISSIONS in packages/app-shared/src/constants/WorkspacePermissions.ts.
  • Who gets what: WORKSPACE_PERMISSIONS_BY_ROLE in packages/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:

ts
@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

ScreenBuilt by
Sign-in, sign-up, email code, workspace onboardingClerk
Workspace switcher and user menu (sidebar)Neatship, calling Clerk
Settings → ProfileNeatship's language setting, then Clerk's account details
Settings → Security (Clerk mode only)Clerk: password, two-step verification, devices, account deletion
Settings → MembersClerk: members, invitations, roles
Settings → GeneralNeatship: 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: anonymous simulates 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=mock when NODE_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 ClerkWithout the webhookWith the webhook
Another member's name or emailwhen that member next opens the appat once
Workspace renamed in the Clerk dashboardneverat once
Person deletednever (Clerk refuses their sign-in anyway)the person and their memberships are soft-deleted
Member removednever (their token no longer names the workspace)the member is soft-deleted
Workspace deletedneverits 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:

  1. Open a tunnel to your server, for example with ngrok http 3100. A tunnel gives your local server a temporary public address.
  2. In the Clerk dashboard, open Webhooks and add the endpoint https://<tunnel address>/webhooks/clerk.
  3. Subscribe to these events: user.updated, user.deleted, organization.updated, organization.deleted, organizationMembership.created, organizationMembership.updated, organizationMembership.deleted.
  4. Copy the endpoint's signing secret (whsec_...) into CLERK_WEBHOOK_SIGNING_SECRET in packages/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):

  1. Choose an email address nobody uses, for example demo@<your domain>, and set DEMO_ACCOUNT_EMAIL (in packages/app-server/.env locally, in packages/app-docker/.env.production in production). Restart the server.
  2. Create the account once: npx nx run app-server:demo-account:reset locally, or bash packages/app-utils/prod-compose.sh run --rm demo-account-reset in 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_KEY must include its BEGIN PUBLIC KEY line 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 .env files, then restart yarn 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.