Repository navigation
Authentication
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.
| 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. |
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,
trustedOriginsor custom session options are configured. - Email verification is not required, and no email is sent;
emailVerifiedstaysfalse. - 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 globalapiprefix.
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.
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 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.
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).
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.
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 athttp://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 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,HEADandOPTIONS, such as sign-in, sign-up and sign-out), better-auth compares the request'sOriginheader, orRefererwhen there is noOrigin, against its trusted origins. With notrustedOriginsconfigured, that list is the origin ofbaseURL. A browser on any other address is refused with403and theINVALID_ORIGINerror 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 setsSecure. -
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 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).
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.
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 asnext. 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
401counts 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.
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) withsafeNext(?next), or/welcomeafter sign-up and/homeafter sign-in, followed byrouter.refresh(). - The link to the other auth page carries a safe
nextvalue across throughauthHref.
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.
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.
- Architecture: where the API, worker and web app run.
- Backend API: routes behind the guard.
-
Data Model: the
user,session,accountandverificationtables. -
Deployment: setting
BETTER_AUTH_URL, HTTPS and exposure. - Troubleshooting: the "invalid origin" sign-in failure.
Job Matcher · Website · Report an issue · MIT license
Using Job Matcher
System design
Matching engine
Interface and brand
Engineering