Skip to content

Authentication

Karthik P edited this page Oct 15, 2026 · 1 revision

Job Matcher authenticates users with better-auth using email and password. better-auth runs inside the NestJS API, stores users, sessions and accounts in Postgres through its Prisma adapter, and is exposed at /api/auth/*. The browser reaches it through the Next.js web app, which rewrites /api/* to the API, so the session cookie is set and sent on the web app's own origin. On the web side, a lightweight proxy.ts redirects based on whether a session cookie exists, the app shell verifies the session itself, and every post-sign-in redirect passes through safeNext, which only allows paths on the same site. This page covers each of those pieces and the rules for BETTER_AUTH_URL.

Components

Piece Source Role
better-auth instance apps/api/src/auth/auth.ts createAuth(prisma) configures secret, base URL, Prisma adapter and email/password.
Nest integration apps/api/src/auth/auth.module.ts AuthModule.forRootAsync from @thallesp/nestjs-better-auth, injecting PrismaService. Mounts the handler and a global guard.
Body parsing apps/api/src/app.ts bodyParser: false; the integration parses JSON and form bodies for every route except /api/auth/*.
Web rewrite apps/web/next.config.ts /api/:path* to ${API_URL}/api/:path*.
Route proxy apps/web/proxy.ts Optimistic redirects based on the presence of a session cookie.
Auth client apps/web/lib/auth-client.ts createAuthClient() from better-auth/react, with no options, so it calls /api/auth on the current origin.
Redirect safety apps/web/lib/safe-next.ts safeNext, authHref, and the default post-auth destinations.
Forms apps/web/components/auth-form.tsx Sign-in and sign-up form.
Session gate apps/web/components/app-shell.tsx Verifies the session for signed-in pages, clears stale cookies, handles sign-out and account switches.

API side

better-auth configuration

betterAuth({
  secret: config.BETTER_AUTH_SECRET,
  baseURL: config.BETTER_AUTH_URL,
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  emailAndPassword: { enabled: true },
});

Everything else is better-auth's default. In particular:

  • No social providers, plugins, trustedOrigins or custom session options are configured.
  • Email verification is not required, and no email is sent; emailVerified stays false.
  • The minimum password length is better-auth's default of 8, which the web form mirrors with minLength={8}.
  • The base path is the default /api/auth, which matches the API's global api prefix.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required by config.ts; the API (and the worker, which loads the same config) will not start without them.

Storage

better-auth's tables are the user, session, account and verification models in prisma/schema.prisma, created by the 20261010120000_add_auth migration. The password hash is stored on the account row whose provider is the credential provider. Application tables reference users only by a plain userId string with no foreign key. See Data Model.

The guard

The Nest integration installs a global guard. Every controller route requires a session, which the guard resolves by calling auth.api.getSession with the request headers (so it reads the session cookie), unless the controller or route is marked @AllowAnonymous(). Only the health controller is. Unauthenticated requests get 401. Controllers receive the session through the @Session() decorator and use session.user.id to scope every query; see Backend API.

Endpoints used by the web app

The handler serves all of better-auth's built-in routes under /api/auth. The web app uses these through the auth client:

Client call Route
authClient.signUp.email({ name, email, password }) POST /api/auth/sign-up/email
authClient.signIn.email({ email, password }) POST /api/auth/sign-in/email
authClient.signOut() POST /api/auth/sign-out
authClient.useSession() GET /api/auth/get-session

Sign-up signs the new user in immediately (better-auth's default autoSignIn).

Sessions and cookies

better-auth keeps sessions in the session table and identifies them with a signed session-token cookie. With the configuration above, the cookie is:

Attribute Value
Name better-auth.session_token, prefixed with __Secure- when BETTER_AUTH_URL starts with https://
HttpOnly yes
SameSite Lax
Secure only with the __Secure- prefix (an https base URL)
Path /
Lifetime better-auth's default session expiry of 7 days

The cookie has no Domain attribute (cross-subdomain cookies are not enabled), so it belongs to the exact host the user opened.

Why the cookie is first-party

The browser never calls the API directly. It calls /api/auth/... on the web app's origin, and Next.js forwards the request to the API server-side (next.config.ts). The Set-Cookie in the response therefore applies to the web origin, and the browser sends the cookie back with every later /api/* request, which the rewrite forwards to the API, where the guard reads it. This gives:

  • No third-party cookies and no CORS configuration.
  • Only the web container is published in production (deploy/compose.yml); the API is reachable only inside the Compose network at http://api:4000.
  • The Next.js proxy can see the cookie on page requests and redirect before rendering.

API_URL is read when the web app is built, because Next.js resolves rewrites at build time. The production image sets it to http://api:4000 through a build argument in the Dockerfile; development defaults to http://localhost:4000.

BETTER_AUTH_URL and origin rules

BETTER_AUTH_URL must be the exact origin people type into the browser to open the web app, including scheme and port. It is not the API's address.

Environment Value
Development (.env.example) http://localhost:3000 (the Next.js dev server)
Self-hosted default http://localhost:8080 (WEB_PORT)
On a LAN http://<host IP>:8080
Behind an HTTPS reverse proxy https://jobs.example.com

Why it must match:

  • Origin check. For state-changing auth requests (anything but GET, HEAD and OPTIONS, such as sign-in, sign-up and sign-out), better-auth compares the request's Origin header, or Referer when there is no Origin, against its trusted origins. With no trustedOrigins configured, that list is the origin of baseURL. A browser on any other address is refused with 403 and the INVALID_ORIGIN error code, which the self-hosting guide lists as the "invalid origin" sign-in failure.
  • Cookie security. An https:// value switches the cookie to its __Secure- name and sets Secure.
  • Links outside the app. The worker builds the run-page link in Discord alerts from it (run-page-url.ts).

To open an instance to another device or a domain, change BETTER_AUTH_URL to that address and restart the stack; see Deployment.

Sign-up exposure

Sign-up is open: email and password sign-up is enabled, there is no invite, allow-list, email verification or sign-up switch, and the sign-up page is public. Anyone who can reach the web app's address can create an account. Each account has its own sources, settings, runs and matches, but all accounts share the instance's API keys, Firecrawl credits and single run worker.

The self-hosting guide's advice follows from this: do not expose an instance to a network or the internet unless you are willing to let anyone there use it. Restricting access today means restricting network access (for example a firewall, VPN, or an authenticating reverse proxy in front of the web app).

Web side

Route proxy (proxy.ts)

proxy.ts is Next.js's request proxy (the successor to middleware.ts). It makes an optimistic check only: whether a session cookie is present, via getSessionCookie from better-auth/cookies, which accepts either the plain or the __Secure- cookie name. It does not call the API or the database, so it can redirect before a page renders.

Visitor Path Result
Has a session cookie / Redirect to /home
Has a session cookie /sign-in or /sign-up Redirect to safeNext(?next), or /home
No session cookie /home, /analyze, /matches, /runs, /settings, /welcome, or any path below them Redirect to /sign-in?next=<path and query> (only if the path is safe)
Anyone anything else Continue

The matcher excludes /api/, /_next/, /favicon.ico and any path with a file extension, so API calls and static assets never pass through it.

Session gate (AppShell)

Because the proxy only checks that a cookie exists, the signed-in layout's AppShell verifies the session with authClient.useSession() and exposes a gate (loading, signed-in or failed) that pages use to decide whether to render:

  • Expired or revoked session. When the session check returns no session, the shell calls authClient.signOut() to clear the stale cookie, clears the React Query cache, and replaces the page with the sign-in URL carrying the current path as next. Clearing the cookie first prevents a loop in which the proxy would send the sign-in page straight back to the app.
  • Check failed. Only a 401 counts as signed out. Any other failure (API down, network error) leaves the cookie alone and the gate shows a retry, since the session may be valid. If clearing the cookie fails, the gate also offers a retry rather than looping.
  • Account switch. If the session changes from one user to another (for example after signing in as someone else in another tab), the shell clears the query cache and remounts the pages, so the previous user's cached data and form state cannot be saved into the new account.
  • Sign-out. The menu's sign-out calls authClient.signOut(), clears the query cache, and replaces the page with /. A failed sign-out shows a toast and leaves the user signed in.

Sign-in and sign-up forms

auth-form.tsx renders both modes (/sign-in and /sign-up):

  • Sign-up collects name, email and password; sign-in collects email and password.
  • Known better-auth error codes map to plain messages: USER_ALREADY_EXISTS (and ..._USE_ANOTHER_EMAIL), INVALID_EMAIL_OR_PASSWORD, PASSWORD_TOO_SHORT. A status of 0 or 500 and above shows "Couldn't reach the server". Other errors show better-auth's message.
  • On success the form clears the React Query cache, then calls router.replace (so Back does not return to the form) with safeNext(?next), or /welcome after sign-up and /home after sign-in, followed by router.refresh().
  • The link to the other auth page carries a safe next value across through authHref.

Safe next redirects

Every redirect target that comes from the URL passes through safeNext in safe-next.ts, in both the proxy and the form, to prevent open redirects. It returns the path to use, or null (meaning "use the default"). A value is refused when it:

Rule Examples refused (from the tests)
Is missing, empty or longer than 2048 characters null, ""
Contains a control character or whitespace (code points up to 0x20, or 0x7f). Browsers drop tabs and newlines from URLs, so /<tab>/evil.com would otherwise be read as //evil.com /<tab>/evil.com, / /evil.com, /home<NUL>
Does not start with /, or contains a backslash settings, https://evil.com, javascript:alert(1), /\evil.com
Resolves to a different origin than a fixed placeholder base //evil.com, ///evil.com
Has a percent-decoded path that starts with //, or contains a backslash or a control character /%2F%2Fevil.com, /%5Cevil.com
Has a path that cannot be percent-decoded /%E0%A4%A
Points at /sign-in or /sign-up after decoding and normalisation (case-insensitive, ignoring trailing slashes, including sub-paths) /sign-in/, /SIGN-IN, /%73ign-in, /home/../sign-up

An accepted value is returned as its normalised pathname, query and hash, so /runs/../settings becomes /settings. authHref(page, next) builds /sign-in?next=... or /sign-up?next=... only when next is safe, and the bare page otherwise. The rules are covered by safe-next.test.ts.

Per-user browser flags

user-flags.ts keys small browser-only flags (closed hints, setup progress) by the signed-in user's id in localStorage (job-matcher:<name>:<userId>), so they never carry over between accounts in the same browser. They are a convenience; when storage is unavailable a flag lasts for the visit only.

Related pages

Clone this wiki locally