Skip to main content

Authentication Architecture

End-to-end authentication for this stack: Express, Passport, express-session with Redis, GraphQL (Apollo Server), and a React / Apollo Client SPA. All behavior below is grounded in the repository; where a capability is only partially present (e.g. mail templates without a public API), that is called out explicitly. RBAC (roles, scopes, middleware) is documented separately: RBAC.md.

1. Overview

High-level summary

  • Identity is established with Passport using a server-side session (serialized user id → deserialized User).
  • Primary API for sign-in / sign-up / sign-out is GraphQL (/graphql), with credentials: true CORS and Apollo configured to send cookies.
  • Password users verify email before sign-in; passwords are stored with bcrypt via Sequelize model hooks; sign-in uses User.validPassword (bcrypt).
  • Google OAuth is optional (env-gated); OAuth hits Express routes under /auth, proxied from the Vite dev server.
  • Multi-tenancy is modeled with organizations and OrganizationMember rows; User.current_membership selects which org context is active for scope checks (see RBAC.md).

Technologies and libraries

LayerTechnology
HTTP serverExpress (apps/backend/src/index.ts)
Sessionexpress-session + connect-redis (apps/backend/src/session.ts)
Authpassport, passport-local, passport-google-oauth20 (apps/backend/src/auth/passport.ts, apps/backend/src/auth/strategies/google.ts)
Password hashingbcrypt on User model hooks + validPassword (apps/backend/src/tools/rds/sequelize/models/user/index.ts)
APIApollo Server 4 + expressMiddleware (apps/backend/src/index.ts)
Frontend transportApollo Client createHttpLink/graphql, credentials implied via same-site cookie usage; authLink reads localStorage key __jwt (apps/frontend/src/main.tsx)

Key architectural decisions (as implemented)

  1. Session cookie auth for GraphQL — Context is { req, res, tools }; resolvers call req.isAuthenticated() / req.login / req.logout. There is no backend middleware that validates a JWT from Authorization for GraphQL in this codebase.
  2. Redis-backed sessions — Horizontally scalable session store with key prefix session: (apps/backend/src/session.ts).
  3. Email verification gatecanSignInWithPassword rejects users that still have email_validation_token set (apps/backend/src/utilities/auth.ts).
  4. OAuth redirect UX — Post-auth browser redirect targets the SPA sign-in route with an optional validated internal redirect query param; the path is carried in a short-lived signed HTTP-only cookie (oauth_post_auth_redirect) so it survives the Google round-trip even when the session id cookie is not preserved (apps/backend/src/utilities/oauthRedirectCookie.ts, apps/backend/src/routers/auth/index.ts). OAuth state remains Passport’s CSRF mechanism.

2. Authentication flow

2.1 Sign-up flow (password)

Entry: GraphQL mutation signUpWithPassword (apps/backend/src/gql/mutations/signUpWithPassword.ts). Notable implementation details:
  • createUser (apps/backend/src/utilities/user.ts) sets email_validated_at to null unless isAlreadyVerified; for password sign-up it calls resetEmailValidation, which sets email_validation_token (hex, 32 random bytes), 24h expiry (getEmailValidationExpiration), and queues verify email when enabled.
  • Verification link shape: buildVerifyEmailLink{frontend}/verify/{token} with optional ?redirect= (apps/backend/src/utilities/redirect.ts).
  • If email already exists, resolver returns success: true (and may surface "Email verification required" when a validation token still exists) — intentional obfuscation of enumeration (signUpWithPassword).

2.2 Sign-in flow (password)

Entry: signInWithPassword (apps/backend/src/gql/mutations/signInWithPassword.ts). Core gate:
export const canSignInWithPassword = async (
  email: string,
  password: string,
  tools: ToolsType,
): CanSignInWithPasswordResult => {
  const rds = tools.rds;
  const logger = tools.logger;
  const defaultError = 'Invalid email or password';

  try {
    const user = await rds.models.User.findOne({
      where: {
        email,
      },
    });

    if (!user) {
      logger.warn('Sign in attempted with non-existent email', { email });
      return { success: false, result: defaultError };
    }

    if ((await user.validPassword(password)) === false) {
      logger.warn('Invalid password provided for user', { email });
      return { success: false, result: defaultError };
    }

    // Check if email is verified
    if (user.email_validation_token) {
      return {
        success: false,
        result: 'Please verify your email before signing in',
      };
    }

    return { success: true, result: user };
  } catch (error) {

2.3 Sign-in flow (Google OAuth)

Entry: Browser navigates to /auth/google (proxied to backend in dev — apps/frontend/vite.config.ts). Router: apps/backend/src/routers/auth/index.ts. Strategy: apps/backend/src/auth/strategies/google.ts. Callback URL configured on the strategy is ${tools.configuration.frontend.url}/auth/google/callback, which matches the SPA origin and Vite proxy rules.

2.4 Session management

Configuration (apps/backend/src/session.ts):
const SESSION_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30;

const initSession = (app: Express, tools: ToolsType) => {
  const sessionCookieDomain = process.env.SESSION_COOKIE_DOMAIN;

  app.use(
    session({
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      store: new RedisStore({
        client: tools.cache.client,
        prefix: 'session:',
      }),
      cookie: {
        maxAge: SESSION_MAX_AGE_MS,
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
        ...(sessionCookieDomain ? { domain: sessionCookieDomain } : {}),
      },
    }),
  );

  app.use(passport.initialize());
  app.use(passport.session());

  app.use(cookieParser());

  // Configure passport strategies
  configurePassport(tools);
};
Passport serialization (apps/backend/src/auth/passport.ts):
  passport.serializeUser((user: User, done) => {
    done(null, user.id);
  });

  passport.deserializeUser(async (id: string, done) => {
    try {
      const user = await tools.rds.models.User.findByPk(id);
      done(null, user || { id });
    } catch (error) {
      tools.logger.error(error, {
        message: 'Failed to deserialize user',
        user_id: id,
      });
      done(error);
    }
  });
GraphQL wiring (apps/backend/src/index.ts) — CORS allows frontend + backend origins with credentials: true; /graphql stack includes passport.session() so req.user is populated from the session cookie. Expiry: maxAge is 30 days (milliseconds expression in code). There is no refresh-token style rotation implemented for sessions beyond store TTL / browser cookie behavior.

2.5 Sign-out flow

Entry: signOut mutation (apps/backend/src/gql/mutations/signOut.ts).

3. Token & session architecture

MechanismPurposeStorageValidated where
Express session idAuthenticated browser sessionsHTTP cookie (session middleware)Redis session store + Passport deserialize on each request
Email validation tokenOne-time email proofusers.email_validation_token + expiry columnsvalidateEmailToken / validateEmail mutation (apps/backend/src/utilities/user.ts, apps/backend/src/gql/mutations/validateEmail.ts)
Invitation tokenOrg invite acceptanceorganization_members.invitation_token + invitation_expires_atacceptInvitation, invitation query, completeInvitedProfile
localStorage.__jwtOptional Bearer for ApolloClient localStorageNot read by backend GraphQL in this repo; no setItem("__jwt") usage found — see §10
JWT: There is no JWT issuance or verification path in the backend for user sessions in the code searched. Apollo Client sends Authorization: Bearer … only if __jwt is set (apps/frontend/src/main.tsx); the server continues to rely on session cookies for me and protected mutations.

4. Password handling

Hashing strategy

  • Algorithm: bcrypt (apps/backend/src/tools/rds/sequelize/models/user/index.ts).
  • Salt rounds: Number(process.env.SALT_ROUNDS) in beforeCreate / beforeUpdate when password changes.
  • Verification: User.validPassword uses bcrypt.compare.
  static buildHooks = () => {
    User.beforeCreate(async (instance: User) => {
      if (instance.password) {
        const salt = await bcrypt.genSalt(Number(process.env.SALT_ROUNDS));
        instance.salt = salt;
        instance.password = await bcrypt.hash(instance.password, salt);
      }
    });

    User.beforeUpdate(async (instance: User) => {
      if (instance.changed('password')) {
        const salt = await bcrypt.genSalt(Number(process.env.SALT_ROUNDS));
        instance.salt = salt;
        instance.password = await bcrypt.hash(instance.password, salt);
      }
    });
  };

Passport LocalStrategy vs actual password login

apps/backend/src/auth/passport.ts registers passport-local and verifies passwords with scrypt against user.password as a raw string comparison. That does not match bcrypt storage on User. No passport.authenticate('local', …) route was found in apps/backend/src/routers. Effective password authentication for the product is GraphQL + bcrypt via canSignInWithPassword.

Password strength

validateUserPassword (apps/backend/src/utilities/user.ts) enforces:
  • 8–40 chars, at least one upper, lower, digit, and one of @.#$!%*?&.

Password reset flow

Not yet implemented — planned for a future release as an end-to-end user flow (no GraphQL mutation for “request password reset” / “confirm reset token” was found in apps/backend/src/gql). What exists today:
  • Mailer template type AvailableTemplates.PASSWORD_RESET and queue consumer (apps/backend/src/tools/mailer/emails/index.ts, apps/backend/src/tools/queue/consumers/mailer/passwordReset.ts).
  • Template builder apps/backend/src/tools/mailer/emails/passwordReset.ts.
These are infrastructure only until wired to resolvers and persistence for reset tokens.

Email verification / reset token behavior

  • validateEmail mutation calls validateEmailToken (apps/backend/src/gql/mutations/validateEmail.ts).
  • Expired validation tokens can trigger resend logic with cooldown (1 hourTOKEN_RESEND_COOLDOWN_HOURS in apps/backend/src/utilities/user.ts).

5. Team invites

How invites are generated

  • inviteMember (apps/backend/src/gql/mutations/inviteMember.ts) uses createInvitationresetInvitation (apps/backend/src/utilities/organization.ts).
  • Token: crypto.randomUUID() (generateInvitationToken).
  • Expiry: INVITATION_EXPIRY_DAYS = 7getInvitationExpiration.
export const INVITATION_EXPIRY_DAYS = 7;

export const getInvitationExpiration = () => {
  return new Date(Date.now() + 1000 * 60 * 60 * 24 * INVITATION_EXPIRY_DAYS);
};

export const isInvitationExpired = (invitation: OrganizationMember) => {
  return invitation.invitation_expires_at && invitation.invitation_expires_at < new Date();
};

export const generateInvitationToken = () => {
  return crypto.randomUUID();
};
  • Email link: {frontend}/invitation/{invitation_token} (queued in resetInvitation).

Accept flow (logged-in user)

Mutation: acceptInvitation (apps/backend/src/gql/mutations/acceptInvitation.ts). Middleware: isLoggedInpopulateUserWithMemberships. Steps:
  1. getOpenInvitationByToken requires invitation_token, matching user_id, invitation_expires_at > now, invitation_accepted_at null.
  2. clearInvitation sets token/expiry null and invitation_accepted_at to now.
  3. Updates user.current_membership to the accepted membership id.

Invite preview query (pre-login)

Query: invitation (apps/backend/src/gql/queries/invitation.ts). Resolver chain has no isLoggedIn middleware — callable without session; returns org name, role, expiry, optional vToken for email verification flows.

Completing profile for invited users

Mutation: completeInvitedProfile (apps/backend/src/gql/mutations/completeInvitedProfile.ts) — guarded by isLoggedOut (must not already have a session). Sets name + password, validates email token + invitation, then req.login.

When an invite expires

ScenarioBehavior
acceptInvitationgetOpenInvitationByToken uses invitation_expires_at: { [Op.gt]: new Date() } — no row → generic failure (STANDARD_HIDDEN_ERROR_MESSAGE).
inviteMember (re-invite)If pending invite exists and invitation_expires_at < now, resetInvitation issues a new token + expiry and sends email (handleExistingOrganizationMember).
completeInvitedProfileReturns explicit message: “Invitation has expired, please ask an administrator to send you a new invitation” when isInvitationExpired(invitation).

6. Relationship to RBAC

Authorization after authentication uses organization membership, active current_membership, and scopes resolved per subscription product. See RBAC.md for:
  • PublicRoles / Roles and User.is_super_admin
  • hasScopes, isAdmin, isAdminOrSuperAdmin, isSuperAdmin
  • AllScopes, Redis caching, ProductRoleScope
Invite-side RBAC: inviteMember requires hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY); resetInvitation also checks MEMBERS_INVITE for the initiator’s membership (apps/backend/src/utilities/organization.ts).

7. Multi-tenancy

Isolation model

  • Organization is the tenant boundary (Organization model).
  • Membership links user_id + organization_id with role (OrganizationMember).
  • Active tenant for the session user is User.current_membership pointing at one OrganizationMember id.

How tenant context flows

  1. Passport loads User by id; GraphQL middlewares often reload with memberships included (populateUserWithMemberships, hasScopes, isAdmin, etc.).
  2. getCurrentMembership(user) picks the membership matching current_membership.
  3. switchOrganization (apps/backend/src/gql/mutations/switchOrganization.ts) validates the user belongs to the target org, then updates current_membership.

RBAC + multi-tenancy

  • Scopes are computed for organizationMember + active subscription’s product (getScopesForOrganizationMembership in apps/backend/src/utilities/rbac.ts).
  • Wrong or missing current_membershipgetCurrentMembership returns null → middleware typically throws 403 or 500 depending on handler.

8. Middleware & guards

GraphQL middleware modules

Located in apps/backend/src/gql/middlewares/:
MiddlewareBehavior
isLoggedInreq.isAuthenticated() else 401 (getGQLError)
isLoggedOutAuthenticated users get 403
populateUserWithMembershipsLoads User + memberships, attaches context.user
hasScopesSuper-admin bypass; else canAccessScopes on current membership
isAdminCurrent membership role === PublicRoles.ADMIN
isAdminOrSuperAdminSuper admin or org admin
isSuperAdmincontext.user.is_super_admin
Chaining uses resolveWithMiddlewares (apps/backend/src/gql/middlewares/index.ts), which invokes the first function in the array with the rest as a nested pipeline.

How to protect a GraphQL resolver

Pattern: resolveWithMiddlewares(_parent, params, context, [middlewares..., actualResolver]). Example (authenticated + memberships):
  return resolveWithMiddlewares(_parent, _form, context, [
    isLoggedIn,
    populateUserWithMemberships,
    async (_parent, form, context: ContextWithUser) => {
Example (scopes):
// Pattern from inviteMember — see apps/backend/src/gql/mutations/inviteMember.ts
return resolveWithMiddlewares(_parent, params, context, [
  isLoggedIn,
  hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY),
  async (_parent, params, context: ContextWithUser) => { /* ... */ },
]);

How to protect an Express route

There is no shared requireAuth Express middleware in apps/backend/src/routers beyond Passport’s session. Current patterns:
  • OAuth routespassport.authenticate handles auth (apps/backend/src/routers/auth/index.ts).
  • Webhooks — e.g. Stripe uses Stripe signature verification, not session (apps/backend/src/routers/api/stripe.ts).
To protect a new Express route, you would add a small middleware that checks req.isAuthenticated() (and optionally reloads the user) after initSession / passport.session() are applied (apps/backend/src/session.ts, apps/backend/src/index.ts). Example sketch (new code — not present verbatim):
const requireSession: express.RequestHandler = (req, res, next) => {
  if (!req.isAuthenticated()) {
    res.sendStatus(401);
    return;
  }
  next();
};

router.get('/example', requireSession, (_req, res) => {
  res.json({ ok: true });
});

9. Extending the auth system

Add a new auth provider (OAuth / SSO)

  1. Add a Passport strategy module under apps/backend/src/auth/strategies/.
  2. Register it inside configurePassport (apps/backend/src/auth/passport.ts) with appropriate env guards (mirror GOOGLE_CLIENT_ID pattern).
  3. Mount routes under buildAuthRouter (apps/backend/src/routers/auth/index.ts) or a new router registered in buildRouters (apps/backend/src/routers/index.ts).
  4. Ensure callback URLs match how the SPA proxies to the backend (apps/frontend/vite.config.ts for dev).
  5. Decide user provisioning rules (see googleStrategyCallback for create/link user + default org) (apps/backend/src/auth/strategies/google.ts).

Add a new role or permission

See RBAC.md — roles and scopes are enums + database mappings + cache.

Customize the invite flow

Touch points:
  • GraphQL: inviteMember, acceptInvitation, invitation, completeInvitedProfile.
  • Utilities: createInvitation, resetInvitation, clearInvitation, getInvitationExpiration (apps/backend/src/utilities/organization.ts).
  • Mail template: ORGANIZATION_INVITATION payload includes invitationLink (resetInvitation).

10. Common pitfalls

  1. passport-local + scrypt vs bcrypt — Do not assume LocalStrategy matches stored passwords; password sign-in is canSignInWithPassword + bcrypt. Using passport.authenticate('local') without changing verification would be incorrect.
  2. localStorage.__jwt — Frontend sends Bearer token if present, but backend GraphQL does not implement JWT auth here; session cookie is what matters. __jwt is never set in this repository’s frontend sources — dead path unless a fork adds it.
  3. Email verification state — Sign-in checks email_validation_token truthiness, not only email_validated_at (canSignInWithPassword). Keep those fields consistent when seeding users.
  4. invitation query typing — Resolver uses ContextWithUser in its signature but does not run isLoggedIn; it is effectively public for invite landing pages (apps/backend/src/gql/queries/invitation.ts).
  5. Session cookie domain — Optional SESSION_COOKIE_DOMAIN env sets cookie.domain; omit in local dev so the browser uses a host-only cookie (works with Vite on :4000 proxying to the API).
  6. Super-admin deserialize edge casedeserializeUser may call done(null, user || { id }) when user missing — downstream code generally expects a full User; monitor for deleted users with live sessions.
  7. No password reset API — Do not point users at reset URLs until GraphQL + persistence exist (§4).
  8. Subscription required for scopes — After login, RBAC may still deny everything if the org has no active subscription (RBAC.md).

Quick reference: key files

ConcernPath
Server bootstrap + GraphQLapps/backend/src/index.ts
Session + Passport attachapps/backend/src/session.ts
Strategiesapps/backend/src/auth/passport.ts, apps/backend/src/auth/strategies/google.ts
OAuth routes + redirectapps/backend/src/routers/auth/index.ts
OAuth redirect cookie (signed)apps/backend/src/utilities/oauthRedirectCookie.ts
Redirect validationapps/backend/src/utilities/redirect.ts
Password sign-in gateapps/backend/src/utilities/auth.ts
User + validation tokensapps/backend/src/utilities/user.ts
User model + bcryptapps/backend/src/tools/rds/sequelize/models/user/index.ts
Invitationsapps/backend/src/utilities/organization.ts, apps/backend/src/gql/mutations/inviteMember.ts, acceptInvitation.ts, completeInvitedProfile.ts, apps/backend/src/gql/queries/invitation.ts
GraphQL context typesapps/backend/src/gql/type.ts
Apollo Clientapps/frontend/src/main.tsx
Dev proxy to backendapps/frontend/vite.config.ts

What’s next?

  • RBAC — how roles and scopes gate feature access once a user is authenticated.
  • Billing — Stripe subscriptions, checkout, and how subscription state drives RBAC.
  • Architecture overview — the full system map and layer summary.