Writing

JWT rotation, IDOR, and the security decisions behind NovaShop

Level: Advanced12 min read
SecurityAuthNestJS

Auth in NovaShop spans three layers: NestJS issues the tokens, Next.js holds the cookies, and middleware quietly refreshes them. Most of the small decisions in that chain are exactly the ones people skip when building auth in a hurry - and exactly the ones attacked most often in production.

1. Separate secrets per token, and a self-declared type

private async generateAccessToken(username: string, userId: number) {
  return this.jwtService.signAsync(
    { username, sub: userId, type: 'access' satisfies JwtTokenType },
    { secret: getJwtAccessSecret(this.configService), expiresIn: '15m' },
  );
}

private async generateRefreshToken(username: string, userId: number) {
  return this.jwtService.signAsync(
    { username, sub: userId, type: 'refresh' satisfies JwtTokenType },
    { secret: getJwtRefreshSecret(this.configService), expiresIn: '7d' },
  );
}

Two separate secrets (JWT_ACCESS_SECRET and JWT_REFRESH_SECRET) mean leaking one does not automatically leak the other. On top of that, each token carries its own type field, and the strategy checks it strictly:

if (payload.type !== 'access') {
  throw new UnauthorizedException('Invalid access token');
}

Without that check, a stolen refresh token - which lives seven days instead of fifteen minutes - could pass as an access token the moment verification gets sloppy: both token kinds pointed at one secret, or a verify call that never asks what the token is for. The separate secrets are the first lock; the type field is the second. The underlying trap is common: a correctly signed JWT does not mean it is the right kind of token for this endpoint.

2. Refresh tokens are never stored in plaintext

async login(username: string, userId: number) {
  const accessToken = await this.generateAccessToken(username, userId);
  const refreshToken = await this.generateRefreshToken(username, userId);
  const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
  await this.userService.updateUser(userId, { refreshToken: hashedRefreshToken });
  return { userId, accessToken, refreshToken };
}

The refresh token is bcrypt-hashed before it touches the database, exactly like a password. If the database is ever dumped - SQL injection, a leaked backup, an insider - the attacker does not walk away with usable refresh tokens. They would have to brute-force bcrypt, which is not realistic. Compared to the common approach of storing the raw token, this is one extra layer that only pays off in the scenario where the database is already compromised.

3. Rotation: the old token dies the moment it is used

async refreshToken(refreshToken: string) {
  const payload = this.jwtService.verify(refreshToken, {
    secret: getJwtRefreshSecret(this.configService),
  });
  if (payload.type !== 'refresh') throw new UnauthorizedException(...);

  const user = await this.userService.findUserById(payload.sub);
  const isMatch = await bcrypt.compare(refreshToken, user.refreshToken);
  if (!isMatch) throw new UnauthorizedException('Invalid refresh token');

  // Refresh token rotation
  return this.login(user.username, user.id);
}

Every refresh calls login() again, which mints a new refresh token and overwrites the stored hash. The old token immediately stops matching bcrypt.compare. That is refresh token rotation as OWASP recommends it: if a refresh token is stolen and used by an attacker, the legitimate owner's next refresh fails - which is a signal that the token was compromised.

What is missing, and worth being honest about: there is no revoke-everything step when reuse is detected. Adding refresh-token reuse detection that kills every session for that user would be the natural next upgrade.

4. Google OAuth: verify server-side, never trust the token as sent

async googleLogin(googleToken: string) {
  const ticket = await this.googleClient.verifyIdToken({
    idToken: googleToken,
    audience: this.configService.get<string>('GOOGLE_CLIENT_ID'),
  });
  const payload = ticket.getPayload();
  if (!payload || !payload.email || !payload.name) {
    throw new UnauthorizedException('Invalid Google token');
  }
}

verifyIdToken with a required audience blocks the classic attack: presenting a genuinely valid Google ID token that was issued for a different OAuth application. Without the audience check, anyone holding a valid Google ID token from any app could sign in.

For a new Google user, the backend generates a random password:

const randomSecurePassword = crypto.randomBytes(32).toString('hex');

crypto.randomBytes is a CSPRNG, unlike Math.random(). That matters because such an account can still, in principle, log in through the credentials route if it is not blocked per provider - and a guessable password there is a back door into every Google-only account.

5. OwnsResourceGuard: stopping IDOR at the framework layer

IDOR - Insecure Direct Object Reference - is near the top of the OWASP list: user A edits an id in the URL and reads or modifies user B's data. NovaShop handles it with one reusable guard:

canActivate(context: ExecutionContext): boolean {
  const paramName =
    this.reflector.getAllAndOverride<string>(OWNS_RESOURCE_KEY, [...]) ?? 'id';
  const request = context.switchToHttp().getRequest();
  const userId = Number(request.user?.id);
  const resourceId = Number(request.params[paramName]);

  if (!userId || userId !== resourceId) {
    throw new UnauthorizedException('You can only access your own resources');
  }
  return true;
}

This beats scattering if (req.user.id !== params.id) across controllers, where it is easy to forget one or write it slightly differently. Here you attach a decorator and the guard compares the userId taken from the verified JWT - not from a body or query parameter a client could forge - against the resource id in the URL.

6. Webhook secret: the trust boundary between Next.js and NestJS

Orders are confirmed through a dedicated internal endpoint - Stripe never calls NestJS directly:

canActivate(context: ExecutionContext): boolean {
  const request = context.switchToHttp().getRequest<Request>();
  const expectedSecret = this.configService.get<string>('INTERNAL_WEBHOOK_SECRET');
  if (!expectedSecret) {
    throw new UnauthorizedException('Webhook secret is not configured');
  }
  const providedSecret = request.headers['x-webhook-secret'];
  if (typeof providedSecret !== 'string' || providedSecret !== expectedSecret) {
    throw new UnauthorizedException('Invalid webhook secret');
  }
  return true;
}

The real chain of trust is: Stripe → a Next.js route handler that verifies the Stripe HMAC signature → NestJS, which verifies its own INTERNAL_WEBHOOK_SECRET. Two independent checks for two different boundaries. If the internal secret leaks, an attacker still cannot forge a genuine Stripe webhook; and verifying a Stripe signature does not by itself grant the right to call the internal NestJS API.

One detail worth copying: the guard fails closed when the secret is missing. Throwing on an empty config, rather than defaulting to allow, is the difference between safe and wide open when someone deploys without the env var set.

7. Cookies are always httpOnly, and tokens never touch localStorage

cookieStore.set({
  name: ACCESS_TOKEN_COOKIE,
  value: tokens.accessToken,
  httpOnly: true,
  secure: isProd,
  sameSite: "lax",
});

Every access and refresh token lives in an httpOnly cookie, so client-side JavaScript - including anything injected by an XSS - cannot read the value. This is the decisive difference from the common pattern of parking a JWT in localStorage for convenience, where any XSS becomes a full account takeover.

The cost is that getting a userId near the client requires decoding the JWT payload server-side in resolveUserId(). A little extra code to keep the rule intact: the token never leaves the server.

8. The race condition rotation creates

Worth naming as a known risk rather than a bug: both middleware.ts and resolveAccessToken() inside authFetch can call /token at the same moment when the access token expires. Because rotation kills the old refresh token on first use, two parallel requests - a tab prefetching several routes, say - will race. One wins; the other gets 'Invalid refresh token' even though the user is perfectly legitimate.

This is the usual price of strict rotation. It is normally solved by giving the old refresh token a short grace period, or by putting a mutex around the refresh in middleware.

A correctly signed token is not the same thing as the right token, used by the right person, at the right endpoint.

Most of this stack follows OWASP guidance: distinct token types, rotation, hashed refresh tokens, httpOnly cookies, OAuth audience verification, a fail-closed webhook guard. The two things I would dig into next are reuse detection that revokes every session, and rate limiting on /token and /login - there is currently no brute-force guard on either.