By NestJSJuly 16, 2026·13 min read

Full-Stack Auth: NestJS + React — Signup, Login & Session Management

Signup and login between NestJS and React: bcrypt hashing, JWTs, CORS, where to keep the token, and protecting routes on both sides.

JWT vs session comes down to where login state lives. With a session, the server stores it and the browser holds only a random ID in a cookie, so logging someone out is a single delete. With a JWT, the signed token carries the claims itself, so any server with the key can check it without a lookup, but it stays valid until it expires. This post explains when to pick each, then builds signup and login between a NestJS API and a React app with JWTs, without the mistakes beginner tutorials make: the token in localStorage, an error message that reveals which emails exist, a password compared as plain text.

The architecture, at a glance

React runs the forms and owns the UI's idea of "who's logged in." NestJS owns the source of truth: it checks credentials, issues a signed token, and verifies that token on every protected request. Nothing about a user's identity is trusted just because the frontend says so. The backend re-verifies it every time.

Sequence: the user fills the signup form; React posts name, email and password to the NestJS AuthController. AuthService hashes the password with bcrypt and inserts the user into PostgreSQL, where a unique index rejects duplicate emails. It signs a JWT, NestJS returns 201 Created with the access token, React keeps it in AuthContext state and redirects to the dashboard.

The signup flow from form to token

Never store a real password. Store a hash.

The single non-negotiable rule of authentication: a password column should never contain a readable password. On signup, the backend runs the plaintext password through bcrypt.hash(password, 10), which produces a one-way scrambled string. There's no bcrypt.unhash(). On login, instead of comparing passwords directly, the backend runs bcrypt.compare(inputPassword, storedHash), which re-derives the hash from the input and checks if it matches, without ever needing to know the original password. Even if your database leaks, attackers get hashes, not passwords. And bcrypt is deliberately slow, which makes brute-forcing every possible password against a stolen hash impractical. Two limits worth knowing: bcrypt only reads the first 72 bytes of a password, so cap the length in your signup DTO, and OWASP's password storage guide now puts Argon2id first, with bcrypt at cost 10 or more as the fallback for existing systems.

auth.service.ts
// UniqueConstraintError comes from 'sequelize'
async signup(dto: SignupDto) {
  const hashedPassword = await bcrypt.hash(dto.password, 10);

  try {
    const user = await this.usersService.create({
      full_name: dto.name, // the API says "name", the column is full_name
      email: dto.email,
      password: hashedPassword, // already hashed: the model must not hash it again
    });
    return this.buildAuthResponse(user);
  } catch (err) {
    // Let the unique index on email decide. A findByEmail check first
    // would race: two signups for the same address could both pass it.
    if (err instanceof UniqueConstraintError) {
      throw new ConflictException('Email already in use');
    }
    throw err;
  }
}

usersService is a thin layer over the User model. If you haven't built that yet, the NestJS + Sequelize post sets it up. Hash in one place only. This series hashes here in AuthService, so the User model has no hashing hook. Doing both stores a hash of a hash, and no login ever matches. Two details from that model matter here. The column is full_name, so the API's name field is mapped onto it. And the model's default scope leaves the password column out of every query, so login calls findByEmailWithPassword, the one method that asks for the hash.

One deliberately vague detail: on login, if the email isn't found, and separately if the password doesn't match, both cases should return the exact same error: "Invalid email or password." Returning different messages ("no account with that email" vs "wrong password") lets an attacker enumerate which emails are registered on your platform. Same message, same status code, every time. There's a second leak to close too: timing. If bcrypt.compare only runs when the user exists, a wrong email comes back noticeably faster than a wrong password, and an attacker can measure that. The login code below compares against a dummy hash when no user is found, so both paths take about the same time. Signup is the exception: "Email already in use" does tell someone that the address is registered. Most products accept that, because a real user needs to know. The stricter option is to always reply "check your email" and send the existing owner a note instead. Either way, rate-limit signup and login, for example with @nestjs/throttler. Without it, nothing stops someone from trying thousands of passwords.

What a JWT actually is

A JWT (JSON Web Token) is a signed, self-contained piece of proof: "this user is who they say they are, as of when this was issued." It's not encrypted (anyone can decode and read the payload), but it is signed. The server signs it with a secret key when issuing it, and can verify that signature on every request without needing to look anything up in a database or a session store. That's what makes JWT auth stateless: the server doesn't need to remember who's logged in; the token itself carries that proof, and the server just checks the signature is valid and hasn't expired.

The payload typically includes a sub claim (the standard JWT field for "subject," usually the user's ID), plus whatever else is useful, like email or name. NestJS's @nestjs/jwt package handles signing (jwtService.signAsync(payload)) and verification for you, with a configurable secret and expiry. A 7-day expiry is common for a simple app, though production systems often use much shorter-lived access tokens paired with a separate refresh token.

auth.service.ts
async login(dto: LoginDto) {
  // The User model's default scope hides the hash, so login asks for it
  const user = await this.usersService.findByEmailWithPassword(dto.email);
  // Always run bcrypt.compare, even for unknown emails, so both
  // failure paths take the same time. DUMMY_HASH is created once at
  // module load: const DUMMY_HASH = bcrypt.hashSync('dummy-password', 10);
  const isMatch = await bcrypt.compare(dto.password, user?.password ?? DUMMY_HASH);

  if (!user || !isMatch) {
    // Same error either way — never reveal which part was wrong
    throw new UnauthorizedException('Invalid email or password');
  }

  return this.buildAuthResponse(user);
}

private async buildAuthResponse(user: User) {
  const payload = { sub: user.id, email: user.email };
  const access_token = await this.jwtService.signAsync(payload, {
    secret: process.env.JWT_SECRET,
    expiresIn: '7d',
  });

  return {
    user: { id: user.id, name: user.full_name, email: user.email },
    access_token,
  };
}

JWT vs session: which one should you use?

Both prove that a request comes from someone who logged in. They differ in what the server has to remember. Here is the comparison that matters in practice:

  • –Where the state lives. Session: on the server (memory, Redis or a table), and the browser holds only an opaque ID. JWT: inside the token. The server keeps only the signing key.
  • –Checking a request. Session: one lookup in the session store per request. JWT: verify the signature and the expiry, with no lookup.
  • –Logout and revocation. Session: delete the record and the user is out on the next request. JWT: the token works until exp, unless you keep a denylist, and a denylist is server state again.
  • –What's visible. A session ID reveals nothing. A JWT payload is only base64url-encoded, so anyone holding the token can read every claim. Keep anything sensitive out of it.
  • –Size. A session ID is a short random string. A JWT carries a header, the claims and a signature, usually a few hundred bytes, on every request.
  • –How it travels. Session: almost always a cookie, so you deal with CSRF. JWT: either an Authorization header (no CSRF, but your JavaScript has to hold the token) or a cookie (the same CSRF story as sessions).

Pick sessions when one web app talks to one backend and you need logout to take effect right away, like an admin panel or anything with money in it. Pick JWTs when several services have to check identity without sharing a session store, or when the client is a mobile app or another API. Most production setups end up in the middle: a short-lived JWT for each request plus a refresh token the server tracks, which is a session wearing a different hat. This post uses JWTs because @nestjs/jwt and Passport make the backend side short. The OWASP JWT cheat sheet is worth a read before you ship either one.

Why CORS shows up the moment you connect React to NestJS

React (on port 5173 in a typical Vite setup) and NestJS (on port 3000) are, as far as the browser is concerned, two different origins. By default the browser won't let JavaScript on one origin read a response from another unless the server allows it. That's CORS (Cross-Origin Resource Sharing). NestJS's app.enableCors({ origin: 'http://localhost:5173' }) is what tells the browser "responses can go to this specific origin." Without it, your fetch or axios call rejects and the console shows a CORS error, even though the backend itself is working fine. This trips up almost everyone the first time. Two details: a request that sends JSON or an Authorization header, like every call in this post, first triggers a preflight OPTIONS request, and the browser only sends the real one if the preflight passes. And CORS only controls who can read the response. Simpler requests, like a plain form post, still reach your server and run, so CORS is not CSRF protection. If you later move the token into a cookie, add credentials: true to enableCors.

main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors({
    origin: process.env.CLIENT_URL ?? 'http://localhost:5173',
  });

  await app.listen(3000);
}
bootstrap();

Where to store the JWT token in React

This is the part beginner tutorials most often get wrong by defaulting to localStorage. Here's the actual tradeoff between the common options.

  • –React state (Context): doesn't survive a page refresh. It's harder to steal than localStorage, but an XSS script can still act as the user while the page is open, for example by sending requests through your Axios instance, whose default header already holds the token. Best for learning and simple apps.
  • –localStorage: survives refresh, but is directly readable by any JavaScript running on the page, including malicious injected scripts (XSS). Convenient, but the riskiest option for token storage.
  • –sessionStorage: similar risk profile to localStorage, but cleared when the tab closes.
  • –HttpOnly cookie: set by the server and invisible to JavaScript, so an XSS script can't steal the token. It can still send requests as the user while the page is open. This is the production-grade choice. Set it with Secure and SameSite=Lax or Strict, and add CSRF protection, since the browser attaches cookies automatically. Chrome already treats cookies without a SameSite setting as Lax, which blocks most CSRF, but set it explicitly.

OWASP is blunt about this. Its Session Management cheat sheet says not to put tokens, JWTs or refresh tokens in localStorage or sessionStorage, because a single XSS bug exposes every one of them. It recommends HttpOnly; Secure; SameSite=Strict cookies, or a backend-for-frontend that keeps tokens on the server entirely.

For a learning project, keeping the token in a React Context (AuthContext) that exposes setSession and clearSession is the simplest reasonable option. It just means users have to log in again after a hard refresh, which is a fine tradeoff while you're building. To persist login across refreshes properly in production, the standard pattern is a short-lived access token plus a longer-lived refresh token stored in an HttpOnly cookie, so the frontend can silently request a new access token when the old one expires.

When you outgrow the in-memory version, the server sets the cookie on login instead of returning the token in the body. @Res({ passthrough: true }) gives you Express's response object while Nest still sends whatever the method returns. Import Response with import type: a new Nest project turns on isolatedModules, and a plain import of a type used in a decorated parameter fails to compile. Sign the token with expiresIn: '15m' here so the cookie and the token expire together.

auth.controller.ts
import { Body, Controller, HttpCode, Post, Res } from '@nestjs/common';
import type { CookieOptions, Response } from 'express';

const COOKIE_OPTIONS: CookieOptions = {
  httpOnly: true, // JavaScript can't read it
  secure: process.env.NODE_ENV === 'production', // HTTPS only in production
  sameSite: 'strict', // not sent on requests started from other sites
  path: '/',
};

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('login')
  @HttpCode(200)
  async login(
    @Body() dto: LoginDto,
    @Res({ passthrough: true }) res: Response, // Nest still sends the return value
  ) {
    const { access_token, user } = await this.authService.login(dto);
    res.cookie('access_token', access_token, {
      ...COOKIE_OPTIONS,
      maxAge: 15 * 60 * 1000, // 15 minutes in ms, same as the token's expiresIn
    });
    return { user }; // the token is no longer in the response body
  }

  @Post('logout')
  @HttpCode(204)
  logout(@Res({ passthrough: true }) res: Response) {
    // Clearing only works with the same path and domain it was set with
    res.clearCookie('access_token', COOKIE_OPTIONS);
  }
}

Three more pieces have to change with it. Add app.use(cookieParser()) in main.ts (from the cookie-parser package) and point the strategy at the cookie instead of the header. On the React side, create the Axios instance with withCredentials: true and drop setAuthToken. On the server, enableCors needs credentials: true with a specific origin, since browsers refuse credentialed responses that allow every origin with *.

jwt.strategy.ts
import type { Request } from 'express';

// Inside the JwtStrategy constructor: read the cookie, not the header
super({
  jwtFromRequest: ExtractJwt.fromExtractors([
    (req: Request) => req?.cookies?.access_token ?? null,
  ]),
  ignoreExpiration: false,
  secretOrKey: secret,
  algorithms: ['HS256'],
});

Two catches. First, the browser now attaches the cookie on its own, so CSRF is your problem. SameSite=Strict blocks most of it, but OWASP treats SameSite as defense in depth, so add a CSRF token or a custom-header check on routes that change data. Second, SameSite works on sites, not origins. localhost:5173 and localhost:3000 are the same site, and so are app.example.com and api.example.com. A frontend and an API on two unrelated domains are not, and a Strict or Lax cookie won't be sent on those fetch calls at all. Keep the API on a subdomain of the app, or proxy it through the frontend's domain.

Protecting routes and attaching the token automatically

On the frontend, a ProtectedRoute component checks isAuthenticated from AuthContext before rendering a page. If there's no valid session, it redirects to /login instead of rendering the protected content, using React Router's <Navigate /> and <Outlet />. Rather than manually attaching Authorization: Bearer <token> to every single API call, a small helper sets it once as a default header on the shared api instance right after login, and every request made through that instance carries the header automatically from then on. A response interceptor can also watch globally for 401 responses and redirect to login the moment a token expires or is rejected, instead of handling that check in every component individually.

On the backend, typing req.user by hand gets old fast. A @CurrentUser() parameter decorator, the pattern NestJS Fundamentals describes, pulls it off the request for you. It takes a few lines with createParamDecorator, and the same file is a good home for the JwtPayload type that the strategy and the controller share.

current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

// The payload signed at login, and what JwtStrategy.validate returns
export interface JwtPayload {
  sub: number;
  email: string;
}

export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): JwtPayload => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);
users.controller.ts
import { Controller, Get, NotFoundException, UseGuards } from '@nestjs/common';
import { CurrentUser } from './current-user.decorator';
// A type used in a decorated parameter needs `import type` when
// isolatedModules is on, as it is in a new Nest project
import type { JwtPayload } from './current-user.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // JwtAuthGuard is `class JwtAuthGuard extends AuthGuard('jwt') {}`
  @UseGuards(JwtAuthGuard)
  @Get('me')
  async getProfile(@CurrentUser() current: JwtPayload) {
    // current is req.user, set by JwtStrategy.validate once the token checks out
    const user = await this.usersService.findById(current.sub);
    if (!user) {
      throw new NotFoundException();
    }
    // The default scope already leaves out the password hash.
    // Picking fields keeps the response shape explicit anyway.
    return { id: user.id, name: user.full_name, email: user.email };
  }
}
jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from './current-user.decorator';

// The strategy JwtAuthGuard runs. Register it in AuthModule's providers.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    // Same JWT_SECRET that buildAuthResponse signs with. Fail at startup,
    // not on the first request, if it's missing.
    const secret = process.env.JWT_SECRET;
    if (!secret) {
      throw new Error('JWT_SECRET is not set');
    }
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: secret,
      algorithms: ['HS256'], // accept only the algorithm you sign with
    });
  }

  // Whatever this returns becomes req.user
  validate(payload: JwtPayload) {
    return payload;
  }
}
api.ts
// Shared Axios instance used by the whole app
export const api = axios.create({ baseURL: 'http://localhost:3000' });

export function setAuthToken(token: string | null) {
  if (token) {
    api.defaults.headers.common.Authorization = `Bearer ${token}`;
  } else {
    delete api.defaults.headers.common.Authorization;
  }
}

// A plain module can't call a function that lives in React context,
// so AuthProvider registers its clearSession here when it mounts:
// useEffect(() => setOnUnauthorized(clearSession), []);
let onUnauthorized = () => {};
export function setOnUnauthorized(fn: () => void) {
  onUnauthorized = fn;
}

api.interceptors.response.use(
  (response) => response,
  (error) => {
    // A wrong password on /auth/login is a 401 too. Let the login
    // form show that error instead of redirecting.
    const isLoginCall = error.config?.url === '/auth/login';
    if (error.response?.status === 401 && !isLoginCall) {
      setAuthToken(null);
      onUnauthorized();
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

// login handler, e.g. inside AuthContext
async function login(email: string, password: string) {
  const { data } = await api.post('/auth/login', { email, password });
  setAuthToken(data.access_token);
  setSession(data.user);
}

React sends a request through a shared Axios instance whose default header adds Authorization: Bearer token. NestJS's AuthGuard verifies the JWT signature and expiry: if valid the request reaches the controller, if invalid or expired it returns 401, which an Axios response interceptor catches to clear the session and redirect to the login page.

How an authenticated request is checked

Refresh tokens, revocation and logout

The 7-day token above is fine while you learn, but it has a hole: if it leaks, it works for 7 days and you can't take it back. The production pattern splits the job in two. The access token lives for minutes and goes on every request. The refresh token lives for days, sits in an HttpOnly cookie with Path=/auth so the browser only sends it to the auth routes (refresh and logout, which both need it), and is only good for getting a new access token.

Make the refresh token a random opaque string (for example 32 bytes from crypto.randomBytes), not a JWT, and store only its SHA-256 hash in a table with the user ID and an expiry. A fast hash is fine here because the token is random, unlike a password. Then rotate: every call to /auth/refresh marks the old token as used and issues a new pair. If a token that was already used shows up again, someone copied it, so revoke every token in that chain and make the user log in again. The OAuth security best practice, RFC 9700, requires refresh tokens held by public clients such as browser apps to be either rotated like this or bound to the client.

Sequence: when the access token expires, React calls POST /auth/refresh and the browser sends the refresh cookie. NestJS looks up the token's hash. If it is valid and unused, NestJS marks it used, stores a new one and returns a new access token with a new refresh cookie. If the token was already used, NestJS revokes the whole chain and returns 401, so the user has to log in again.

Refresh token rotation with reuse detection

Logout follows from that. Clearing React state is only the client half. The server half is a POST /auth/logout that deletes the refresh token's row and clears its cookie. The current access token still works until it expires, which is why it should be short. When you need a cut-off right now, after a password change or a ban, put a jti claim (a unique token ID) in each access token, keep a denylist of revoked IDs that expires with the tokens, and check it in JwtStrategy.validate. OWASP warns against keying that list on the raw token or its hash, since the same token can have more than one valid encoding. A Redis key with a TTL is the usual store, which the Redis post covers.

Rate-limit login and signup with @nestjs/throttler

bcrypt makes each guess slow, but nothing above stops someone from making a lot of guesses. @nestjs/throttler adds a limit per client IP. Register it globally with a loose default, then tighten it on the auth routes. Note that ttl is in milliseconds in current versions.

app.module.ts + auth.controller.ts
import { APP_GUARD } from '@nestjs/core';
import { Throttle, ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    // Default for every route: 100 requests per minute per IP
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}

// auth.controller.ts: much tighter on the routes that check passwords
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('login')
login(@Body() dto: LoginDto) {
  return this.authService.login(dto);
}

Over the limit, the client gets a 429. Two things trip people up in production. Behind a load balancer or proxy, every request looks like it comes from the proxy's IP, so one busy user blocks everyone. Set Express's trust proxy setting (the Nest docs show app.set('trust proxy', 'loopback') on a NestExpressApplication) so the real client IP is used. And the default storage is in memory, so with three API instances each one counts separately. A shared store such as the community Redis storage fixes that.

Common questions

Is JWT better than session authentication?

Neither wins everywhere. Sessions are simpler and can be revoked instantly, which suits one web app with one backend. JWTs skip the per-request lookup and work across services, at the cost of harder revocation. Short JWTs plus server-tracked refresh tokens give you most of both.

Where should I store a JWT token?

In an HttpOnly, Secure, SameSite cookie set by the server, which is what OWASP recommends. Not in localStorage or sessionStorage, where any XSS script can read it. Keeping it in React memory is a reasonable learning setup, but it's gone after a refresh.

How do you log out a user with JWT?

Delete the refresh token on the server, clear its cookie, and clear the client state. The access token keeps working until it expires, so keep it short, and add a jti denylist if you need it to stop immediately.

How long should a JWT access token last?

There's no single standard number. Minutes rather than days is the usual advice, and 5 to 15 minutes paired with a refresh token is a common choice. The 7 days in this post only makes sense for a learning app with no refresh token.

Should I use bcrypt or Argon2 in NestJS?

OWASP lists Argon2id first, and the argon2 npm package works the same way: hash on signup, verify on login. bcrypt with a cost of 10 or more is still acceptable, but it ignores everything past 72 bytes, so cap the length in your signup DTO with @MaxLength() from class-validator (set up in the NestJS validation post). It counts characters, not bytes, and a non-ASCII character can take up to four bytes, so check Buffer.byteLength(password) <= 72 if you need the limit exact.

This post is one stop in a backend developer roadmap built on NestJS, PostgreSQL, Redis, BullMQ and Elasticsearch. Next in the series: Redis Essentials, where sessions and rate limits get a proper home. I'm a frontend engineer who builds the UI on top of APIs like this one. If you need someone who can build that UI and talk to your backend team, see my work or get in touch.