HIGH credential stuffingrailsjwt tokens

Credential Stuffing in Rails with Jwt Tokens

Credential Stuffing in Rails with Jwt Tokens — how this specific combination creates or exposes the vulnerability

Credential stuffing is an automated attack in which lists of breached username and password pairs are systematically attempted against a login endpoint. When Rails applications use JSON Web Tokens (JWT) for authentication, a common misconception is that server-side sessions are required to mitigate these attacks; however, JWTs themselves do not prevent automated credential guessing. If token issuance does not include strong rate controls or per-user attempt tracking, attackers can iterate through credential lists and observe whether a valid JWT is issued, signaling a valid account.

JWTs are typically stateless, containing claims such as sub (subject) and exp (expiration). If the authentication endpoint issues a token upon any successful password verification without additional checks, attackers can rely on predictable token formats to infer success. For example, a token signed with a weak secret or using a weak algorithm (such as none or HS256 with a guessable secret) can be tampered with or enumerated. Moreover, if token revocation is not implemented—by maintaining a denylist or leveraging short-lived tokens with refresh token rotation—compromised tokens remain usable across multiple login attempts, increasing the window for abuse.

The Rails ecosystem commonly uses the jwt gem to encode and decode tokens. If developer code skips necessary validation steps, such as verifying the issuer (iss), audience (aud), or expiration (exp), or if the payload embeds sensitive data without encryption, the attack surface widens. Additionally, if account enumeration exists—such as different HTTP status codes or response messages for existing versus non-existing accounts—attackers can further refine their credential lists, making successful stuffing more efficient.

Consider a typical JWT issuance flow in Rails:

class AuthController < ApplicationController
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      token = encode_token({ sub: user.id })
      render json: { token: token }, status: :ok
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end

  private

  def encode_token(payload)
    JWT.encode(payload, Rails.application.credentials.secret_key_base, 'HS256')
  end
end

This example illustrates potential weaknesses: the endpoint does not enforce rate limits, treats user existence via a generic error message but may still leak timing differences, and issues a JWT with no per-request nonce or token invalidation mechanism. Without additional protections, an attacker can submit thousands of requests per minute, validating credentials against issued JWTs.

Middleware security checks, such as those performed by scanners, examine whether authentication endpoints enforce strict rate limiting, whether tokens are short-lived, and whether responses avoid leaking account existence. These scans also assess token cryptographic hygiene, including algorithm validation and proper signature verification, ensuring that tokens cannot be trivially forged or reused across sessions.

Jwt Tokens-Specific Remediation in Rails — concrete code fixes

To secure JWT-based authentication in Rails against credential stuffing, implement rate limiting at the endpoint level, introduce per-user attempt tracking, and ensure tokens are short-lived and verifiable. Below are concrete code examples that address these concerns.

1. Enforce rate limiting using rack-attack to limit login attempts per IP or per email:

# config/initializers/rack_attack.rb
class Rack::Attack
  throttle('logins/ip', limit: 5, period: 60) do |req|
    req.ip if req.path == '/login' && req.post?
  end

  throttle('logins/email', limit: 5, period: 60) do |req|
    req.params['email'] if req.path == '/login' && req.post?
  end

  self.throttled_response = lambda do |env|
    [429, { 'Content-Type' => 'application/json' }, [{ error: 'Too many attempts, try later' }.to_json]]
  end
end

This configuration restricts login attempts to five per minute per IP and per email, reducing the feasibility of automated stuffing.

2. Use short-lived access tokens and rotate refresh tokens. Issue an access token with a short expiration and a refresh token stored securely (e.g., httpOnly cookie) with its own expiration and rotation:

class AuthController < ApplicationController
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      access_token = encode_token({ sub: user.id, exp: 15.minutes.from_now.to_i })
      refresh_token = SecureRandom.urlsafe_base64
      user.update(refresh_token: refresh_token, refresh_token_exp: 14.days.from_now)
      cookies.http_only = true
      cookies.secure = Rails.env.production?
      cookies[:refresh_token] = { value: refresh_token, expires: 14.days.from_now }
      render json: { access_token: access_token }, status: :ok
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end

  private

  def encode_token(payload)
    JWT.encode(payload, Rails.application.credentials.secret_key_base, 'HS256')
  end
end

3. Validate token claims rigorously on each request in a before_action:

class ApiController < ApplicationController
  before_action :authenticate_request

  private

  def authenticate_request
    header = request.headers['Authorization']
    token = header.split(' ').last if header
    begin
      decoded = JWT.decode(token, Rails.application.credentials.secret_key_base, true, { algorithm: 'HS256' })
      @current_user = User.find(decoded[0]['sub'])
    rescue JWT::ExpiredSignature
      render json: { error: 'Token expired' }, status: :unauthorized
    rescue JWT::DecodeError
      render json: { error: 'Invalid token' }, status: :unauthorized
    end
  end
end

4. Implement token denylist for immediate revocation on password change or logout:

class Denylist < ApplicationRecord
  self.table_name = 'jwt_denylist'
  attr_accessor :jti, :exp

  def self.jti_in_denylist?(jti)
    where(jti: jti).where('exp > ?', Time.now).exists?
  end
end

# When logging out or changing credentials:
Denylist.create(jti: decoded_token['jti'], exp: decoded_token['exp'])

# In authenticate_request, after decoding:
if Denylist.jti_in_denylist?(decoded[0]['jti'])
  render json: { error: 'Token revoked' }, status: :unauthorized
end

By combining these practices—rate limiting, short-lived tokens, strict validation, and revocation—you reduce the effectiveness of credential stuffing against Rails applications that rely on JWTs.

Frequently Asked Questions

Does a valid JWT always indicate a successful credential stuffing attack?
Not necessarily. A valid JWT confirms that the supplied credentials were accepted by the server, but it does not by itself confirm whether the token was used maliciously. Monitoring token usage patterns and pairing short-lived tokens with rate limits helps distinguish legitimate sign-ins from automated attacks.
Can JWTs be encrypted to prevent exposure of claims in Rails?
Yes, you can use JSON Web Encryption (JWE) to encrypt token payloads in Rails. When encryption is required, ensure the implementation uses strong algorithms and key management, and validate that your decoding logic handles both encrypted and signed tokens appropriately.