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.