Back to writing
Glib Rulev
Glib Rulev

Preventing Brute-Force Attacks in Node.js and Golang Applications

Brute-force attacks are one of the oldest tricks in a hacker’s playbook - automated scripts hammering your login endpoints with thousands of credential combinations, hoping something sticks. But with a few layered security measures, you can make it practically impossible for attackers to succeed.

This post walks through practical strategies to protect your Node.js and Golang applications, backed by code samples and real-world system design insights.


🔑 Key Takeaways

  • Rate limiting slows down repeated requests attempts per IP or user.
  • Account lockout disables accounts temporarily after too many failures.
  • CAPTCHA ensures a human is behind the request.
  • Secure password hashing makes it harder to crack stolen credentials.
  • IP blocking shuts down suspicious sources proactively.

🚨 What is a Brute-Force Attack?

A brute-force attack uses automated tools to guess credentials or tokens through rapid, repeated attempts. These attacks often target:

  • /auth/login or other authentication endpoints
  • Password reset forms
  • API keys or Bearer token–protected endpoints

If successful, attackers may gain access to user data, escalate privileges, or compromise your entire system.


🧠 How Brute-Force Attacks Exploit System Design Weaknesses

Let’s walk through real-world scenarios that show how attackers take advantage of common architectural gaps:

🧪 Example 1: Credential Stuffing on Public Login APIs

Scenario: An attacker obtains a leaked credentials dump and bombards your login endpoint using thousands of proxy IPs.

Fix:

  • Add rate limiting at the API Gateway
  • Track login attempts by email + IP
  • Introduce CAPTCHA after N failed attempts

🔐 Example 2: Bypassing IP-Based Throttling with Botnets

Scenario: You throttle logins per IP, but attackers use a botnet to spread attempts across 1000+ IPs.

Fix:

  • Track attempts per username regardless of IP
  • Implement account lockouts
  • Add velocity checks per user identity

🎯 Example 3: Abuse of Password Reset Endpoints

Scenario: An attacker targets your password reset feature to discover valid accounts or brute-force reset tokens.

Fix:

  • Use neutral responses like: “If your account exists…”
  • Enforce reset rate limits per user
  • Generate strong, short-lived reset tokens

⚙️ Example 4: Brute-Forcing API Keys or Bearer Tokens

Scenario: Your API uses tokens without monitoring. The attacker runs scripts to guess valid tokens.

Fix:

  • Limit token scope and IP range
  • Monitor and alert on failed auth spikes

🛡️ Defense Strategies Node.js and Go

1. Rate Limiting

NestJS Example

NestJs provides a ThrottlerModule module for integration of rate-limiting in application.

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60_000,      // 1 minute
      limit: 5          // Max 5 requests per minute per IP
    }),
  ],
  providers: [
	// All rate-limits are applied globally if this guard is provided
    { provide: APP_GUARD, useClass: ThrottlerGuard }
  ],
})
export class AppModule {}

Fastify Example

Fastify provides @fastify/rate-limit plugin for integration of rate-limiting in application.
This plugin will add an onRequest hook to check if a client (based on their IP address) has made too many requests in the given timeWindow. If client reaches the maximum number of allowed requests, an error will be sent to the user with the status code set to 429.

// server.js
import Fastify from 'fastify';
import fastifyRateLimit from '@fastify/rate-limit';

const app = Fastify();

app.register(fastifyRateLimit, {
  max: 10,
  timeWindow: '1 minute'
});

app.post('/login', async (req, reply) => {
  // login logic
});

Golang Example

import (
  "github.com/ulule/limiter/v3"
  "github.com/ulule/limiter/v3/drivers/store/memory"
  "github.com/ulule/limiter/v3/drivers/middleware/stdlib"
  "net/http"
  "time"
)

func main() {
	rate := limiter.Rate{
	    Period: 1 * time.Minute,
		Limit:  10,
	}
	
	store := memory.NewStore()
	instance := limiter.New(store, rate)
	middleware := stdlib.NewMiddleware(instance)
	
	http.Handle("/login", middleware.Handler(http.HandlerFunc(loginHandler)))
	http.ListenAndServe(":3000", nil)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Login logic goes here"))
}

2. Account lockout

Fastify Example

// server.js
import Fastify from 'fastify';

const app = Fastify();

const lockout = new Map(); // In-memory. Use Redis in production.

app.post('/login', (req, res) => {
	const { email, password } = req.body;
	const attempts = lockout.get(email) || 0;

	if (attempts >= 5) {
	    return res.status(429).send('Account locked. Try again in 15 minutes.');
	}

	const isValid = checkCredentials(email, password);

	if (!isValid) {
	  lockout.set(email, attempts + 1);
	  return res.status(401).send('Invalid credentials');
	}

	lockout.delete(email); // Reset on success
	res.send('Login successful');
});

// This is a placeholder. Replace with actual credential checking.

func checkCredentials(email, password string) bool {
	return email == "[email protected]" && password == "securepassword"
}

Golang Example

var lockout sync.Map

func loginHandler(w http.ResponseWriter, r *http.Request) {
	email := r.FormValue("email")
	password := r.FormValue("password")

	value, _ := lockout.LoadOrStore(email, 0)
	attempts := value.(int)

	if attempts >= 5 {
	    http.Error(w, "Account locked", http.StatusTooManyRequests)
	    return
	}

	if !checkCredentials(email, password) {
	    lockout.Store(email, attempts+1)
	    http.Error(w, "Invalid credentials", http.StatusUnauthorized)
	    return
	}

	lockout.Delete(email)
	w.Write([]byte("Login successful"))
}

3. Password Hashing

Node.js Example

There is a bcrypt module in Node.js which is widely used for working with password hashing, but i’d like to provide a native Node.js implementation of password hashing and comparing.

import crypto from 'node:crypto';

const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const SCRYPT_PREFIX = '$scrypt$N=32768,r=8,p=1,maxmem=67108864$';
const SALT_LEN = 32;
const KEY_LEN = 64;

const serializeHash = (hash: Buffer, salt: Buffer) => {
	const saltString = salt.toString('base64').split('=')[0];
	const hashString = hash.toString('base64').split('=')[0];
	
	return `${SCRYPT_PREFIX}${saltString}$${hashString}`;
};

const parseHashOptions = (options: string): Record<string, number> => {
	const values: [string, number][] = [];
	const items = options.split(',');
	for (const item of items) {
		const [key, val] = item.split('=');
		values.push([key, Number(val)]);
	}

	return Object.fromEntries(values);
};

const deserializeHash = (phcString: string) => {
	const [, name, options, salt64, hash64] = phcString.split('$');
	if (name !== 'scrypt') {
		throw new Error('Node.js crypto module only supports scrypt');
	}
	const params = parseHashOptions(options);
	const salt = Buffer.from(salt64, 'base64');
	const hash = Buffer.from(hash64, 'base64');

	return { params, salt, hash };
};

export const hashPassword = (password: string): Promise<string> => new Promise((resolve, reject) => {
		crypto.randomBytes(SALT_LEN, (err, salt) => {
			if (err) {
				reject(err);
				return;
			}

			crypto.scrypt(password, salt, KEY_LEN, SCRYPT_PARAMS, (err, hash) => {
				if (err) {
					reject(err);
					return;
				}
				resolve(serializeHash(hash, salt));
			});
		});
});

export const validatePassword = (
	password: string,
	serHash: string,
): Promise<boolean> => {
	const { params, salt, hash } = deserializeHash(serHash);
	return new Promise((resolve, reject) => {
		const callback = (err: Error | null, hashedPassword: Buffer) => {
			if (err) {
				reject(err);
				return;
			}

			resolve(crypto.timingSafeEqual(hashedPassword, hash));
		};

		crypto.scrypt(password, salt, hash.length, params, callback);
	});
};

Golang Example

import (
  "golang.org/x/crypto/bcrypt"
)

func hashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	return string(bytes), err
}

func comparePassword(hashed, password string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil
}

4. Suspicious IP Blocking

We can handle this in application level, but it is better to handle using tools like fail2ban, Cloudflare WAF, or dynamic detection systems in production.


✅ Best Practices Recap

  • Defense-in-depth: Combine rate limiting, lockouts, CAPTCHA
  • Log and alert: Monitor failed logins by IP, user, and route
  • Token hygiene: Use short-lived access tokens and revoke old ones
  • Avoid weak defaults: Enforce strong passwords at signup
  • Audit regularly: Follow OWASP authentication guidelines

🔚 Final Thoughts

Brute-force attacks are inevitable - but successful ones are preventable. Designing your system with layered security will frustrate attackers and protect your users.

By rate-limiting suspicious activity, locking out persistent failures, verifying users with CAPTCHA, and storing credentials securely, you reduce the blast radius of abuse.