Your API logs show a flood of requests from a single IP — 400 attempts in 30 seconds. By the time your rate limiter kicks in, your database has already taken 400 unnecessary hits. IP reputation scoring lets you make a risk decision before a request ever touches your business logic.
This is not about blocking entire countries or trusting commercial blocklists blindly. It is about building a lightweight, composable scoring layer that combines signals: past behavior, known threat lists, and ASN metadata.
What goes into a reputation score
A reputation score is a number (0–100, higher = riskier) aggregated from several signals:
- Recent failed requests: failed auth attempts, 429s, 4xx errors from this IP in the last hour
- Threat intelligence feeds: lists like AbuseIPDB, Emerging Threats, or Spamhaus
- ASN/organization: requests from hosting providers (AS14061 DigitalOcean) carry more weight than residential ISPs
- Behavioral velocity: requests-per-minute, unique endpoints hit, unusual timing patterns
Each signal contributes a sub-score. The final score is a weighted sum. You tune the weights based on what a false positive costs your business.
Data structures and scoring logic
Here is a Go struct and scoring function that implements the basics:
package reputation
import (
"math"
"time"
)
type IPRecord struct {
IP string
FailedAuths int
Total4xx int
RequestsPerMin float64
InThreatFeed bool
IsHostingASN bool
LastSeen time.Time
}
type Score struct {
Value int // 0-100, higher = riskier
Reason string
}
func Calculate(r IPRecord) Score {
score := 0.0
var reasons []string
// Failed auth signals (max 40 points)
if r.FailedAuths > 0 {
authScore := math.Min(float64(r.FailedAuths)*4, 40)
score += authScore
if r.FailedAuths >= 5 {
reasons = append(reasons, "repeated_auth_failures")
}
}
// 4xx rate (max 20 points)
if r.Total4xx > 10 {
score += math.Min(float64(r.Total4xx-10)*0.5, 20)
}
// Velocity (max 20 points)
if r.RequestsPerMin > 60 {
score += math.Min((r.RequestsPerMin-60)*0.5, 20)
reasons = append(reasons, "high_velocity")
}
// Threat feed match (30 points flat)
if r.InThreatFeed {
score += 30
reasons = append(reasons, "threat_feed_match")
}
// Hosting ASN multiplier
if r.IsHostingASN {
score *= 1.3
reasons = append(reasons, "hosting_asn")
}
finalScore := int(math.Min(score, 100))
reason := "ok"
if len(reasons) > 0 {
reason = reasons[0]
}
return Score{Value: finalScore, Reason: reason}
}
The weights are opinionated — adjust them to your traffic profile. A failed auth from a hosting ASN that also appears in a threat feed can easily hit 90+. A residential IP with a few stray 404s stays below 20.
Building the middleware
The scorer is most useful as HTTP middleware that short-circuits requests above a threshold:
package middleware
import (
"fmt"
"net"
"net/http"
"strings"
"yourapp/reputation"
"yourapp/store"
)
const BlockThreshold = 75
const ChallengeThreshold = 50
func ReputationCheck(s store.IPStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractIP(r)
record, err := s.Get(ip)
if err != nil {
// Fail open — never block on infrastructure errors
next.ServeHTTP(w, r)
return
}
score := reputation.Calculate(record)
w.Header().Set("X-Reputation-Score", fmt.Sprintf("%d", score.Value))
switch {
case score.Value >= BlockThreshold:
http.Error(w, "forbidden", http.StatusForbidden)
return
case score.Value >= ChallengeThreshold:
// Upstream can decide to require CAPTCHA or step-up auth
r.Header.Set("X-Reputation-Challenge", "true")
}
next.ServeHTTP(w, r)
})
}
}
func extractIP(r *http.Request) string {
// Only trust X-Forwarded-For if a controlled proxy sets it
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}
One critical detail in extractIP: only trust X-Forwarded-For if a trusted reverse proxy sets it. If your app is directly exposed, a client can forge any IP in that header and bypass scoring entirely.
Feeding the store and integrating threat feeds
The IP store accumulates signals as requests complete. A Redis-backed store with TTL-expiring counters works well at scale:
// Run this after next.ServeHTTP returns so you have the final status code
func recordOutcome(ip string, statusCode int, s store.IPStore) {
const ttl1Hour = 3600
const ttl1Minute = 60
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
s.IncrField(ip, "failed_auths", 1, ttl1Hour)
}
if statusCode >= 400 {
s.IncrField(ip, "total_4xx", 1, ttl1Hour)
}
s.IncrField(ip, "request_count", 1, ttl1Minute)
}
For threat feed lookups, AbuseIPDB offers a free tier. Query it asynchronously on first sight of an IP, then cache the result with a 24-hour TTL — you do not want a synchronous external call on every request:
func checkAbuseIPDB(ip, apiKey string) (bool, error) {
req, _ := http.NewRequest("GET", "https://api.abuseipdb.com/api/v2/check", nil)
q := req.URL.Query()
q.Add("ipAddress", ip)
q.Add("maxAgeInDays", "30")
req.URL.RawQuery = q.Encode()
req.Header.Set("Key", apiKey)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var result struct {
Data struct {
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, err
}
return result.Data.AbuseConfidenceScore > 25, nil
}
For ASN lookups, MaxMind GeoLite2-ASN ships as a local .mmdb file and resolves in microseconds — no network round-trip needed.
Tuning and avoiding false positives
Before flipping the middleware on in production:
- Run in shadow mode first: compute and log scores but do not block anything. Collect a week of data.
- Histogram your scores: if 30% of legitimate traffic scores above 50, your weights are off.
- Allowlist known IPs: your CI runner, monitoring probes, partner services — seed them with score 0 in the store.
- Alert on block rate spikes: a sudden jump in blocks often means a signal source is misbehaving — a threat feed returning stale bulk data or a counter TTL bug.
For a broader hardening checklist covering authentication, headers, and rate limiting, our free security hardening checklists cover these patterns in structured PDF and Excel format.
The takeaway
IP reputation scoring is not a silver bullet. A motivated attacker rotates IPs; a residential botnet scores low on ASN checks. But as a first-pass filter it cuts noise significantly — automated scanners almost always trigger your highest-scoring thresholds within the first 10 requests.
The pattern scales cleanly: start with in-memory counters, graduate to Redis, then layer in external feeds as traffic volume justifies the operational complexity. Keep the fail-open behavior in the middleware. The cost of a false positive — blocking a legitimate user — is almost always higher than the cost of letting one more bot through.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)