Skip to content

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 tokenRefresh token
CarriesAuthorization: Bearer <jwt> headerrefresh_token cookie
LivesIn memory on the client (an Angular signal)HttpOnly; SameSite=Lax; Secure (prod only); Path=/api/auth cookie
Lifetime15 minutes7 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/reloadsYes
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 when NODE_ENV=production, so the cookie still works over plain http://localhost in dev. If you ever serve prod over http, this is the flag that stops working — check config.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 need SameSite=None; Secure plus real CSRF defense (the current design gets CSRF protection for free from Lax + the narrow Path, not from a token).
  • Path=/api/auth — narrower than the __Host- cookie prefix would allow. __Host- requires Path=/, 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 every login(), deletes that one user’s rows whose expiresAt has 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 @Cron job, global across all users, that deletes any row whose expiresAt passed 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 at expiresAt on a revoked row, only at revokedAt and replacedBy.
  • enforceSessionCap (auth.service.ts) — after register/login mints a new session, if the user now has more than MAX_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 is 400 INVALID_OR_EXPIRED_TOKEN.
  • POST /auth/resend-verification {email} mails a fresh link and supersedes the previous one. It always answers 202, 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 answers 202. 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: always 202, 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 is 400 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: 200

Two 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 call POST /refresh. Naively, both would pass the revoked/expired checks server-side and race a conditional updateMany({ where: { id: row.id, revokedAt: null } }): one wins (count === 1, mints a new session), the other gets count === 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 record

Refusals: 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 accessToken

The 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

PieceFile
Issues/rotates/revokes tokens, session caplibs/core/api/auth/src/lib/services/auth.service.ts
Daily cleanup of long-dead rowslibs/core/api/auth/src/lib/services/refresh-token-cleanup.service.ts
HTTP routes, sets/clears the cookielibs/core/api/auth/src/lib/controllers/auth.controller.ts
Cookie name/path/optionslibs/core/api/security/src/lib/utils/refresh-cookie.ts
Reads the Bearer token, no DB hitlibs/core/api/auth/src/lib/strategies/jwt.strategy.ts
Which organization a session starts inlibs/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 callslibs/core/client/auth-data-access/src/lib/services/auth.service.ts
Attaches Bearer header, retries on 401libs/core/client/auth-data-access/src/lib/interceptors/auth.interceptor.ts
Blocks routes until a session is confirmedlibs/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 usual 401, so a stranger learns nothing about the account.

Endpoints

EndpointResponse
POST /api/auth/register202 { email }, 409 if the email exists
POST /api/auth/login200, 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 attempt

The user can still press “resend”. Alert on that line in production (see monitoring).

Where the code lives

WhatWhere
Sending mail (SMTP, templates EL/EN)libs/core/api/mail/ (@core/api-mail)
Tokens, verify, resend, limitslibs/core/api/auth/src/lib/services/email-verification.service.ts
Register / login / endpointslibs/core/api/auth/src/lib/services/auth.service.ts, controllers/auth.controller.ts
Schema / migrationlibs/core/db/prisma/core/{user,auth}.prisma (EmailVerificationToken, User.emailVerifiedAt), migrations/*_email_verification
Client pageslibs/core/client/auth-feature/src/lib/components/{check-email,verify-email,resend-verification}
Email copylibs/core/api/mail/src/lib/templates/verification.ts
E2E testsapps/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

  1. .env: it needs these keys (they are also in .env.example):

    APP_URL=http://localhost:4200
    MAIL_FROM="Keelmint <no-reply@keelmint.local>"
    SMTP_HOST=localhost
    SMTP_PORT=1025
    SMTP_SECURE=false

    Without APP_URL, MAIL_FROM, SMTP_HOST, SMTP_PORT or JOBS_ENCRYPTION_KEY (or TWO_FACTOR_ENCRYPTION_KEY) the API refuses to start. That is deliberate, so a misconfiguration never goes unnoticed.

  2. Start Postgres + Mailpit:

    Terminal window
    docker compose up -d db mailpit
  3. Migration:

    Terminal window
    pnpm install
    pnpm nx run keelmint-db:migrate
  4. Run it:

    Terminal window
    pnpm nx run keelmint-api:serve
    pnpm nx run keelmint-client:serve

Trying it out

  1. Register at http://localhost:4200/auth/register.
  2. Open http://localhost:8025. The email is there.
  3. Click the link. It opens /auth/verify-email and confirms the email.
  4. Log in.

Mailpit commands

Terminal window
docker compose up -d mailpit # start
docker compose stop mailpit # stop
docker compose logs -f mailpit # logs
curl -X DELETE localhost:8025/api/v1/messages # delete every message

Mailpit keeps nothing important. Restarting it is fine.

E2E tests

They need Postgres and Mailpit running:

Terminal window
docker compose up -d db mailpit
pnpm nx e2e keelmint-api-e2e

The 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

ProviderNotes
PostmarkExcellent deliverability for transactional mail like this one. Recommended.
Amazon SESVery cheap. Starts in a sandbox that only sends to verified addresses until you request “production access” (takes about a day).
Mailgun / Brevo / ResendEasy 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.

RecordTypeWhat it does
SPFTXTSays which servers may send for the domain
DKIMTXT or CNAMECryptographic signature on each message
DMARCTXT on _dmarc.example.grWhat 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)CNAMEFor 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_FROM must 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.gr
MAIL_FROM="AppName <no-reply@example.gr>"
SMTP_HOST=smtp.postmarkapp.com # from the provider
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=... # from the provider
SMTP_PASS=... # from the provider (secret)
  • APP_URL is the public URL of the client (Angular), not the API. The link in the email points there. It must be a full https://... 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 true enables SMTP_SECURE.
  • If you leave SMTP_USER empty, 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_PROXY to the number of proxies in front of the API (e.g. 1) or to their IPs/CIDRs; configureApp applies it as Express trust proxy. Never true (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_FROM address (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 with openssl rand -base64 32), TWO_FACTOR_ENCRYPTION_KEY (required, same on every instance, separate from the jobs key; see Two-factor authentication), CORS_ORIGIN if the client is on another origin).
  • Storage for uploaded files: STORAGE_DIR on a persistent volume, or STORAGE_DRIVER=s3 with S3_BUCKET and S3_REGION (Helpdesk). The API refuses to boot in production without one of them.
  • TRUST_PROXY set if there is a proxy in front (see proxy).
  • Migrations applied by the deploy’s migrate service (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

SymptomLikely cause
API does not start: APP_URL: is requiredMissing 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) URLAPP_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 spamSPF/DKIM/DMARC missing or wrong; MAIL_FROM not on the verified domain
Link in the email points to localhostAPP_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 nothingWithin 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 reachabledocker 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.