Authentication
How a session is created, kept alive and ended, how registration and email verification work, and what email delivery needs in production. Read it before you change anything about login, or when you set up SMTP for a real domain. Google login and two-factor authentication have their own pages: Google login, Two-factor authentication.
The two tokens
| Access token | Refresh token | |
|---|---|---|
| Carries | Authorization: Bearer <jwt> header | refresh_token cookie |
| Lives | In memory on the client (an Angular signal) | HttpOnly; SameSite=Lax; Secure (prod only); Path=/api/auth cookie |
| Lifetime | 15 minutes | 7 days (REFRESH_TOKEN_TTL_DAYS), rotates on every use, sliding — see caveat below |
| Readable by JS? | Yes (it has to be, to set the header) | No |
| Survives a page reload? | No — wiped when the tab closes/reloads | Yes |
| Revocable server-side? | No (just expires) | Yes — deleted/revoked row in refresh_tokens |
The access token is what authorizes each API call. The refresh token’s only job is minting a new access token without asking for the password again. Splitting them this way means an XSS that steals the in-memory access token gets at most 15 minutes of access and can’t read the refresh cookie at all.
Cookie attributes, in full (refresh-cookie.ts):
HttpOnly— always.Secure— only whenNODE_ENV=production, so the cookie still works over plainhttp://localhostin dev. If you ever serve prod over http, this is the flag that stops working — checkconfig.get('NODE_ENV')reaches'production'there.SameSite=Lax— assumes the client and API are same-site (e.g. same registrable domain, different subdomains). If they’re ever split across unrelated domains, this stops being sent cross-site on the fetches this app makes, and you’d needSameSite=None; Secureplus real CSRF defense (the current design gets CSRF protection for free fromLax+ the narrowPath, not from a token).Path=/api/auth— narrower than the__Host-cookie prefix would allow.__Host-requiresPath=/, which is broader than this cookie needs; the narrow path is the deliberate trade-off, so this cookie intentionally doesn’t use that prefix.
Sliding window, no absolute cap: every rotation resets the 7-day clock
from now. A refresh token that keeps getting used — a browser tab left open
and revisited daily, say — never hits a hard stop. Most designs cap the whole
token family at some absolute age (e.g. 30 days from first login) in
addition to the sliding per-token TTL. Not implemented here.
Row lifecycle: how refresh_tokens doesn’t grow forever
Rotation never deletes a row — it flips revokedAt and leaves the row in
place (that’s what makes reuse detection possible: a replayed row still
exists to be recognised as “already used,” instead of looking like any other
unknown token). Left alone, that means one row per rotation, forever. Three
things bound it:
pruneDeadTokens(auth.service.ts) — runs on everylogin(), deletes that one user’s rows whoseexpiresAthas already passed. Scoped to a single user and only triggered by a real login, so a session that just keeps refreshing via its cookie and never logs in again is never swept by this alone.RefreshTokenCleanupService(refresh-token-cleanup.service.ts) — a daily@Cronjob, global across all users, that deletes any row whoseexpiresAtpassed more than 30 days ago. This is what actually bounds table growth; the 30-day buffer past a row’s own TTL exists purely for post-incident audit, not for reuse detection — that logic never looks atexpiresAton a revoked row, only atrevokedAtandreplacedBy.enforceSessionCap(auth.service.ts) — afterregister/loginmints a new session, if the user now has more thanMAX_ACTIVE_SESSIONS_PER_USER(default 10) active rows, the oldest excess ones are deleted outright (not revoked — there’s no successor to answer a presentation of them, this is capacity management, not a security event). Bounds worst case per user; without it, N device logins means N permanently-active chains.
RefreshToken is indexed on userId (the “kill every session” query) and on
expiresAt (what the cleanup job scans) - tokenHash gets an index for free
from its @unique constraint, which is what every refresh() call looks up.
Register / Login
Register does not start a session. It creates the user, emails a link
(/auth/verify-email?token=...) and answers 202 {email} with no cookie.
Login then refuses with 403 EMAIL_NOT_VERIFIED until that link has been
followed. Once verified, login issues a token pair.
sequenceDiagram participant B as Browser (AuthService) participant A as API (/api/auth) participant D as Postgres
B->>A: POST /login {email, password} A->>D: verify password hash (login) A->>D: insert refresh_tokens row (SHA-256 of a random token) A-->>B: 200 body {accessToken} + Set-Cookie refresh_token Note over B: accessToken kept in memory (signal)<br/>cookie is HttpOnly, JS never sees it B->>A: POST /register creates the user and emails a verify link (202, no session)Two more endpoints finish and retry verification:
POST /auth/verify-email {token}marks the email verified (204). A link works once and expires; a bad, used or expired token is400 INVALID_OR_EXPIRED_TOKEN.POST /auth/resend-verification {email}mails a fresh link and supersedes the previous one. It always answers202, whether or not the email exists or is already verified, so it cannot be used to probe for accounts. Limits, per user: at most 1 per 60 seconds and at most 5 per rolling hour, counting superseded links. Over the limit it sends nothing and still answers202. It answers before looking the email up, so its timing reveals nothing either.
Two more recover a forgotten password:
POST /auth/forgot-password {email}mails a reset link valid for one hour and supersedes the previous one. Same rules as resend: always202, at most 1 per 60 seconds and 5 per rolling hour per user. It, too, answers before the lookup.POST /auth/reset-password {token, newPassword}sets the password (204). It also marks the email verified, since following the link proves the inbox, and deletes every refresh token of the user, so all sessions end. No session starts: the client sends the user to log in. A bad, used or expired token is400 INVALID_OR_EXPIRED_TOKEN.
The response body never contains the refresh token — only the cookie does. The client only ever holds the access token.
With two-factor authentication on, the login above stops after the password:
it answers { twoFactorRequired, challengeToken } and sets no cookie, and
POST /api/auth/2fa/verify does the session part instead. See
Two-factor authentication.
Every authenticated request
sequenceDiagram participant B as Browser participant I as authInterceptor participant A as API
B->>I: any HTTP call I->>I: attach Authorization: Bearer <accessToken> I->>A: forward request A-->>I: 200 (or 401 if expired)If the access token is missing or expired, the API returns 401 and the interceptor takes over (next section) instead of the call just failing.
Silent refresh (401 auto-retry, and page reload)
sequenceDiagram participant B as Browser participant I as authInterceptor participant S as AuthService participant A as API (/api/auth/refresh)
B->>I: request with expired accessToken I->>A: original request A-->>I: 401 I->>S: refresh() S->>A: POST /refresh (cookie sent automatically) A->>A: rotate refresh_token row, mint new access token A-->>S: 200 {accessToken} + Set-Cookie (new refresh_token) S-->>I: new accessToken I->>A: retry original request with new token A-->>I: 200Two places call refresh():
- The interceptor, reactively, whenever any request 401s.
authGuard, proactively, once on app load / route navigation — this is what makes a page reload land you back on the page instead of/login, since the access token signal is empty right after a reload but the cookie is still there.
Concurrent 401s (e.g. 3 API calls fire at once and all expire together) share
one in-flight refresh() call, so the cookie only rotates once, not three
times.
Rotation is the security net: every refresh both issues a new refresh token and invalidates the old one. If an already-rotated (i.e. stolen and replayed) token is ever presented again, the server treats it as theft and kills every session for that user, not just the one request.
Cross-tab refresh race, and the 30s grace window that answers it. The in-flight-refresh coalescing in the client’s
AuthService.refresh()only dedupes calls within one tab — it’s a plain object field, not shared across tabs. Two tabs reloading at the same moment both read the same cookie and both callPOST /refresh. Naively, both would pass the revoked/expired checks server-side and race a conditionalupdateMany({ where: { id: row.id, revokedAt: null } }): one wins (count === 1, mints a new session), the other getscount === 0— indistinguishable, on its face, from a genuine stolen-token replay. Treating it as theft would delete every row for that user, including the winner’s just-created one: an ordinary double-refresh would log the user out everywhere.
RefreshToken.replacedById(auth.service.ts,rotate()/handleRevokedToken()) fixes this: every rotation points the row it revokes at the row it created. When an already-revoked token is presented - whether caught mid-race or replayed a moment later - and that pointer leads to a successor that is (a) still alive and (b) was created within the last 30 seconds, the request is answered with the successor’s access token instead of being rotated again or wiped. No new cookie is set in that case; the browser’s cookie jar is shared across tabs, so it already holds whichever cookie the winning call set.The trade-off, deliberately accepted: for those 30 seconds after a rotation, a genuinely stolen-and-replayed copy of the immediately preceding token in the chain gets the same free pass as a race loser - family-wide revocation only kicks in once the grace window has passed, or once the successor itself has already been used or revoked. A short grace window is the standard trade in refresh-token rotation schemes for exactly this reason. A token revoked by logout never has a
replacedById(nothing rotated it), so replaying a logged-out cookie gets no grace, ever.
Active organization
A session acts in one organization at a time (concept and rules: organizations.md). It lives in two places: the access token’s claims, and the organizationId column of the session’s refresh_tokens row, so a refresh keeps it.
The access token carries isPlatformAdmin, organization ({ id, name, slug } or null), roles and permissions (the org-wide ones) of the user’s membership in that organization, and unitGrants (the grants of unit-scoped roles, expanded over the unit tree and encoded compactly; about 150 covered units fit). The API reads them with no database query, so a role, unit or membership change reaches a token at its next refresh, up to the 15-minute access-token lifetime.
At session start (login, 2FA verify, Google, invitation accept then login) the organization is User.lastOrganizationId if the user is still a live member of an ACTIVE organization there, else their oldest live membership in an ACTIVE organization, else none (/no-organization). A platform admin never has one: login (2FA verify included), refresh and Google sign-in give them organization: null and no organization roles or permissions, whatever their memberships, and their lastOrganizationId is neither read nor written. They work across every organization and name one per request with X-Organization-Id (Organizations); the client lands them on /users after login.
Refresh re-validates the session’s organization the same way. If the membership was removed or the organization suspended, the session falls back to the oldest live membership (or none). Removing a member and suspending an organization also delete the refresh tokens bound to that organization, so the old organization cannot be refreshed back into.
sequenceDiagram participant B as Browser participant A as API (/api/auth/organization) participant D as Postgres
B->>A: POST /organization {organizationId} (Bearer + refresh cookie) A->>D: not a platform admin; membership live and organization ACTIVE? A->>D: rotate refresh row, bound to the new organization A->>D: users.lastOrganizationId = organizationId A-->>B: 200 {accessToken} + Set-Cookie refresh_token (new) Note over B: client navigates home: the current page<br/>may show the other organization's recordRefusals: 403 NOT_A_MEMBER, 403 ORGANIZATION_SUSPENDED, 403 PLATFORM_ADMIN_NO_SWITCH (a platform admin has no active organization to switch). The switch is audited as ORGANIZATION_SWITCHED. GET /api/auth/organizations lists what the switcher offers: the caller’s own memberships, platform admins included (who cannot switch into them; Organizations).
Every @Entity route needs an active organization (403 ORGANIZATION_REQUIRED without), except for a platform admin, who has none: their requests reach every organization, or the one named with X-Organization-Id.
Sign in with Google
How it works and how to set it up: google-login.md. Tokens
from the Google redirect travel only in the URL fragment (#challenge=,
#token=), never in a query. A session needs none: the refresh cookie is set
on the callback’s redirect.
Logout
sequenceDiagram participant B as Browser participant A as API (/api/auth/logout) participant D as Postgres
B->>A: POST /logout (Bearer token + cookie, both still valid) A->>D: mark refresh_tokens row revoked A-->>B: 204 + Set-Cookie refresh_token (cleared) Note over B: AuthService clears the in-memory accessTokenThe access token isn’t “revoked” (JWTs can’t be), it’s just no longer useful — the client drops it and the server won’t let it get renewed since the refresh cookie behind it is dead.
Where each piece lives
| Piece | File |
|---|---|
| Issues/rotates/revokes tokens, session cap | libs/core/api/auth/src/lib/services/auth.service.ts |
| Daily cleanup of long-dead rows | libs/core/api/auth/src/lib/services/refresh-token-cleanup.service.ts |
| HTTP routes, sets/clears the cookie | libs/core/api/auth/src/lib/controllers/auth.controller.ts |
| Cookie name/path/options | libs/core/api/security/src/lib/utils/refresh-cookie.ts |
| Reads the Bearer token, no DB hit | libs/core/api/auth/src/lib/strategies/jwt.strategy.ts |
| Which organization a session starts in | libs/core/api/auth/src/lib/utils/active-organization.ts |
| Switching organization (rotates the session) | libs/core/api/auth/src/lib/services/organization-switch.service.ts |
| In-memory token + refresh/login/logout calls | libs/core/client/auth-data-access/src/lib/services/auth.service.ts |
| Attaches Bearer header, retries on 401 | libs/core/client/auth-data-access/src/lib/interceptors/auth.interceptor.ts |
| Blocks routes until a session is confirmed | libs/core/client/auth-data-access/src/lib/guards/auth.guard.ts |
Email verification
A new user has to click a link sent to their email before they can log in: registering sends the link and issues no session until it is followed. This part covers how it works, local testing with Mailpit, and what production needs (SMTP provider, domain, DNS).
How it works
The flow
Register ──► 202 { email } ──► client: /auth/check-email?email=... │ ("check your inbox" + resend button) └─► email with link: APP_URL/auth/verify-email?token=XXXX │User clicks ──► client: /auth/verify-email ──► POST /api/auth/verify-email │ 204 ──► "confirmed" ──► /auth/login ──► 200 (session) 400 ──► "link expired or already used"- Register creates the user and sends the email. It does not start a session: no access token, no refresh cookie.
- Login of an unverified user with the right password returns
403 { code: 'EMAIL_NOT_VERIFIED' }. The login page then shows a “resend email” button. A wrong password returns the usual401, so a stranger learns nothing about the account.
Endpoints
| Endpoint | Response |
|---|---|
POST /api/auth/register | 202 { email }, 409 if the email exists |
POST /api/auth/login | 200, 401 wrong credentials, 403 EMAIL_NOT_VERIFIED |
POST /api/auth/verify-email { token } | 204 ok, 400 INVALID_OR_EXPIRED_TOKEN |
POST /api/auth/resend-verification { email } | always 202, sent before the lookup (reveals nothing, by status or by timing) |
Tokens and limits
- The token is 32 random bytes (base64url). The database stores only its sha256, never the token itself.
- It is valid for 24 hours and works once.
- A resend invalidates (soft delete) the previous links. Only the newest one works.
- Resend limits per user: at most 1 per 60 s and at most 5 per hour. Invalidated tokens count toward the limit too. The check runs inside a transaction under a Postgres advisory lock, so parallel requests cannot get past it.
- On top of that, the new public routes are behind
ThrottlerGuard(per IP).
What happens when sending fails
The email is a background job, queued in the same transaction as the token (for resend, that transaction runs just after the 202; see Background jobs). If SMTP is down, register and resend still answer 202, and the mail is retried for about half an hour. Only after the last attempt is an error logged:
Mail job <job id> (verification) failed on its last attemptThe user can still press “resend”. Alert on that line in production (see monitoring).
Where the code lives
| What | Where |
|---|---|
| Sending mail (SMTP, templates EL/EN) | libs/core/api/mail/ (@core/api-mail) |
| Tokens, verify, resend, limits | libs/core/api/auth/src/lib/services/email-verification.service.ts |
| Register / login / endpoints | libs/core/api/auth/src/lib/services/auth.service.ts, controllers/auth.controller.ts |
| Schema / migration | libs/core/db/prisma/core/{user,auth}.prisma (EmailVerificationToken, User.emailVerifiedAt), migrations/*_email_verification |
| Client pages | libs/core/client/auth-feature/src/lib/components/{check-email,verify-email,resend-verification} |
| Email copy | libs/core/api/mail/src/lib/templates/verification.ts |
| E2E tests | apps/keelmint/api-e2e/src/api/api.spec.ts (describe('email verification')), support/mailpit.ts |
To change the email copy, edit templates/verification.ts. It holds both the
EL and EN text, and the email’s language follows the user’s language.
Local setup (Mailpit)
Locally, no mail ever leaves your machine. The API sends everything over SMTP to Mailpit, a fake mail server in Docker that catches every message and shows it in a web UI.
First time
-
.env: it needs these keys (they are also in.env.example):APP_URL=http://localhost:4200MAIL_FROM="Keelmint <no-reply@keelmint.local>"SMTP_HOST=localhostSMTP_PORT=1025SMTP_SECURE=falseWithout
APP_URL,MAIL_FROM,SMTP_HOST,SMTP_PORTorJOBS_ENCRYPTION_KEY(orTWO_FACTOR_ENCRYPTION_KEY) the API refuses to start. That is deliberate, so a misconfiguration never goes unnoticed. -
Start Postgres + Mailpit:
Terminal window docker compose up -d db mailpit- SMTP:
localhost:1025(the API sends here) - Web UI: http://localhost:8025 (where you read the mail)
- SMTP:
-
Migration:
Terminal window pnpm installpnpm nx run keelmint-db:migrate -
Run it:
Terminal window pnpm nx run keelmint-api:servepnpm nx run keelmint-client:serve
Trying it out
- Register at http://localhost:4200/auth/register.
- Open http://localhost:8025. The email is there.
- Click the link. It opens
/auth/verify-emailand confirms the email. - Log in.
Mailpit commands
docker compose up -d mailpit # startdocker compose stop mailpit # stopdocker compose logs -f mailpit # logscurl -X DELETE localhost:8025/api/v1/messages # delete every messageMailpit keeps nothing important. Restarting it is fine.
E2E tests
They need Postgres and Mailpit running:
docker compose up -d db mailpitpnpm nx e2e keelmint-api-e2eThe e2e suite starts its own API, which always sends to Mailpit
(localhost:1025) no matter what your .env says, so it never reaches a real
SMTP server. It reads the messages from Mailpit’s HTTP API (localhost:8025).
If Mailpit lives elsewhere, set MAILPIT_URL, MAILPIT_SMTP_HOST and
MAILPIT_SMTP_PORT.
Production: what is needed
The code does not change. You need an SMTP provider, a domain with DNS records, and the right env vars.
Pick an SMTP provider
| Provider | Notes |
|---|---|
| Postmark | Excellent deliverability for transactional mail like this one. Recommended. |
| Amazon SES | Very cheap. Starts in a sandbox that only sends to verified addresses until you request “production access” (takes about a day). |
| Mailgun / Brevo / Resend | Easy setup, free tier. All of them offer SMTP. |
Do not use a personal Gmail or Outlook account. They have low limits and will block you.
If you ever want to switch to a provider’s HTTP API instead of SMTP, you only
write a new MailTransport in libs/core/api/mail/ and bind it in
mail.module.ts. Nothing else changes.
Domain and DNS (the most important step)
When you have a domain (e.g. example.gr), the provider gives you records to
add to its DNS. Without them the mail goes to spam or never arrives.
| Record | Type | What it does |
|---|---|---|
| SPF | TXT | Says which servers may send for the domain |
| DKIM | TXT or CNAME | Cryptographic signature on each message |
| DMARC | TXT on _dmarc.example.gr | What a receiver does with a message that fails SPF/DKIM. Start with v=DMARC1; p=none; rua=mailto:dmarc@example.gr and tighten later to quarantine/reject |
| Return-Path (some providers) | CNAME | For bounces |
- Take the exact values from the provider’s dashboard. They differ per provider.
- DNS changes can take a few hours to propagate.
- The address in
MAIL_FROMmust be on the domain you verified. - It is common to send from a subdomain, e.g.
no-reply@mail.example.gr, so the main domain’s reputation stays separate.
Env vars on the server
Put them in the hosting panel or secrets manager, never in the repo:
APP_URL=https://app.example.grMAIL_FROM="AppName <no-reply@example.gr>"SMTP_HOST=smtp.postmarkapp.com # from the providerSMTP_PORT=587SMTP_SECURE=falseSMTP_USER=... # from the providerSMTP_PASS=... # from the provider (secret)APP_URLis the public URL of the client (Angular), not the API. The link in the email points there. It must be a fullhttps://...URL, otherwise the API will not start.- Port and TLS:
587+SMTP_SECURE=false: nodemailer upgrades to TLS by itself (STARTTLS). This is the usual choice.465+SMTP_SECURE=true: TLS from the first byte.- Only the exact string
trueenablesSMTP_SECURE.
- If you leave
SMTP_USERempty, no auth is sent. That is only for local Mailpit.
Migration on deploy
Migrations run on every deploy before the new API version starts: the migrate service in Deploy does it.
Rolling deploy: if old and new code run side by side for a while, anyone who registers through the old code after the migration ends up unverified without a link. They recover with “resend” on the login page. It only matters for that short window.
Rate limiting behind a proxy (check before going live)
ThrottlerGuard counts per IP. If the API runs behind a reverse proxy or
load balancer (Nginx, Cloudflare, a platform LB) and TRUST_PROXY is not set,
every user looks like the same IP. Register and login would then be capped
at 5 per minute for everyone combined.
- Fix: set
TRUST_PROXYto the number of proxies in front of the API (e.g.1) or to their IPs/CIDRs;configureAppapplies it as Expresstrust proxy. Nevertrue(refused at boot): clients could then choose their IP. - Decide the value together with the hosting setup.
Monitoring
- Add an alert on the log line
Mail job ... failed on its last attempt(it covers verification, reset and invitation mail). Without it you will not learn that mail has stopped going out: register keeps returning 202. - The provider’s dashboard shows delivered, bounced and spam complaints per message.
On the day the site goes live (checklist)
Do these in order.
A few days before
- Domain bought and DNS under your control.
- Account with an SMTP provider (see SMTP provider). SES: request production access.
- SPF / DKIM / DMARC added (see domain and DNS). The provider’s dashboard shows the domain as verified.
- Decide on the
MAIL_FROMaddress (e.g.no-reply@example.gr).
Deploy
- Env vars on the server:
APP_URL,MAIL_FROM,SMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASS(see env vars), plus the existing ones (DATABASE_URL,JWT_SECRET,JOBS_ENCRYPTION_KEY(required, same on every instance; generate withopenssl rand -base64 32),TWO_FACTOR_ENCRYPTION_KEY(required, same on every instance, separate from the jobs key; see Two-factor authentication),CORS_ORIGINif the client is on another origin). - Storage for uploaded files:
STORAGE_DIRon a persistent volume, orSTORAGE_DRIVER=s3withS3_BUCKETandS3_REGION(Helpdesk). The API refuses to boot in production without one of them. -
TRUST_PROXYset if there is a proxy in front (see proxy). - Migrations applied by the deploy’s
migrateservice (see Deploy). - The API starts. If it complains about
APP_URL/MAIL_FROM/SMTP_*/JOBS_ENCRYPTION_KEY/TWO_FACTOR_ENCRYPTION_KEY, a key is missing or empty.
Right after
- Register with a real Gmail and a real Outlook account.
- The email arrives in the inbox, not spam. If it lands in spam, check SPF/DKIM/DMARC with https://www.mail-tester.com.
- The link opens the production site (not localhost), confirms, and login works.
- The Greek and English email copy both read correctly.
- Log alert for failed sends is active (see monitoring).
First week
- Watch bounces and spam complaints in the provider dashboard.
- If everything is clean, tighten DMARC to
p=quarantine.
Troubleshooting
| Symptom | Likely cause |
|---|---|
API does not start: APP_URL: is required | Missing key in .env (see local setup) or on the server (see env vars) |
API does not start: APP_URL must be an absolute http(s) URL | APP_URL is empty or lacks https:// |
| Register 202 but no email arrives (local) | Mailpit is not running: docker compose up -d mailpit |
| Register 202 but no email arrives (production) | Look in the log for Mail job ... failed on its last attempt: wrong SMTP_*, wrong port/TLS, or the domain is not verified at the provider |
| Email goes to spam | SPF/DKIM/DMARC missing or wrong; MAIL_FROM not on the verified domain |
| Link in the email points to localhost | APP_URL on the server is wrong |
| “Link expired or already used” | Older than 24 h, already used, or a newer link was sent. Log in and press “resend” |
| “Resend” seems to do nothing | Within 60 s of the last email, or 5 in the last hour. Intentional, and the server deliberately does not say so |
E2E: Mailpit is not reachable | docker compose up -d mailpit |
Known limitations
- Expired link + forgotten password: the “resend” button only appears after login with the right password. A user who has also forgotten their password uses “Forgot password” instead: a completed reset verifies the email too.
- Work in flight at a crash: resend answers 202 before the token and mail job are written. A crash in that window loses that one mail; the user presses “resend” again.
- Already verified + old link: in a rare race, an already-verified user who opens an old link sees “expired” instead of “confirmed”. They can still log in normally.
- Changing email from the profile is not supported: a user’s email is fixed once the account exists.