Skip to content

Authentication

rocambille edited this page Jun 4, 2026 · 3 revisions

Summary: StartER offers a complete and secure password-less authentication implementation (Magic Link), based on opaque tokens stored in the database and a session JWT stored in a secure cookie.

How authentication works

The process in StartER relies on a login link sent by email:

  1. The user enters their email address.
  2. The server generates an opaque token (random bytes), hashes it, stores it in the database, and sends the link via SMTP (or displays it in the terminal during development).
  3. The user clicks the link and reaches a verification page.
  4. The server checks the token's validity (not expired, not consumed).
  5. A session JWT token is generated and stored in an HTTP-only cookie.
  6. The React frontend is notified of the login via AuthContext.

Implementation in StartER

Server-side: Express

Authentication features are grouped in the auth module:

src/express/modules/auth/
├── authActions.ts    (Business logic and middleware)
├── authRepository.ts (Database token access)
└── authRoutes.ts     (Endpoint definitions)

Main endpoints

Method Route Action Description
POST /api/auth/magic-link sendMagicLink Sends the login link by email
POST /api/auth/verify verifyMagicLink Verifies the opaque token and sets the cookie
POST /api/auth/logout destroyAccessToken Logout (deletes the cookie)
GET /api/users/me readMe Returns the connected user (req.me)

Sending the link (sendMagicLink) and hybrid email strategy

When a user requests a link, the server finds or creates the user, generates a secure random token, and then stores its SHA-256 hash in the database.

To facilitate local development without requiring the immediate configuration of a tool like Docker or Mailpit, StartER adopts a hybrid email approach:

  • In development: if the SMTP_URL variable is not defined in your .env, the magic login link will not be sent by email, but will be displayed in the console of your terminal. You can click on it to log in.
  • In production: SMTP_URL becomes mandatory. The server will refuse to start without it to ensure the application is actually capable of sending emails to real users.
const magicLink = `${trustedBaseUrl}/verify?token=${rawToken}`;

if (transporter) {
  // The transporter has been configured with the SMTP_URL variable from the .env
  await transporter.sendMail({
    from: "starter@mail.com",
    to: email,
    subject: "Lien de connexion",
    html: `<a href="${magicLink}">Cliquez ici pour vous connecter</a>`,
  });
} else {
  // Development fallback: display the link in the console
  console.info(`Magic Link for ${email}:`);
  console.info(magicLink);
}

Verification and session (verifyMagicLink)

When the user clicks the link:

  1. The client sends the token to the server.
  2. The server compares the received hash with the one in the database.
  3. If valid, it generates a session JWT and places it in a secure cookie:
const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};

res.cookie("__Host-auth", sessionToken, cookieOptions);

verifyAccessToken middleware and req.me

Some routes require identifying the user. StartER injects the complete User entity into req.me.

const verifyAccessToken: RequestHandler = (req, res, next) => {
  const token = req.cookies["__Host-auth"];
  const payload = auth.verify(token); // Verifies the JWT signature

  // Retrieves the fresh user from the database
  const me = userRepository.find(Number(payload.sub));

  if (me == null) throw new Error("User not found");

  // Refresh cookie (extends expiration)
  const freshToken = auth.signSession({ sub: me.id.toString() });
  res.cookie("__Host-auth", freshToken, cookieOptions);

  req.me = me; // Injects the user into the request

  next();
};

This secures the application: if a user is deleted from the database, their JWT becomes instantly invalid even if it has not expired.

Client-side: React

Logic is centralized in src/react/components/auth/AuthContext.tsx.

Login flow

  1. MagicLinkForm: Email entry and call to /api/auth/magic-link.
  2. VerifyPage: /verify route that retrieves the token from the URL and sends it to /api/auth/verify.

Session persistence

When the application loads, the React router (via the root route loader function) calls /api/users/me. If the cookie is present and valid, the user is retrieved and automatically injected into the AuthProvider component.

// src/react/routes.tsx
export const routes = [
  {
    loader: async ({ request }) => {
      // On the server: explicit cookie forwarding
      const response = await fetch("/api/users/me", {
        headers: { cookie: request.headers.get("cookie") ?? "" },
      });
      const me = response.ok ? await response.json() : null;
      return { me };
    },
    Component: () => {
      const { me } = useLoaderData<{ me: User | null }>();
      return <AuthProvider initialUser={me}>...</AuthProvider>;
    }
  }
];

Best practices and security

  1. Single-use tokens: once verified, the Magic Link token is marked as "consumed" in the database.
  2. Token hashing: opaque tokens are never stored in plain text (SHA-256).
  3. HTTP-only cookies: the session JWT is invisible to JavaScript, protecting against session theft via XSS.
  4. Trusted identity (req.me): using the req.me object in your controllers ensures you are handling an existing and up-to-date user.

See also

Clone this wiki locally