- Author: Syed Jahanzaib ~A Humble Human being! nothing else
- Platform: aacable.wordpress.com
- Category: ISP Network Architecture
- Audience: ISP Network Engineers & System Administrators
Disclaimer & Note on Writing Style
Every network environment is unique. A solution that works effectively in one infrastructure may require modification in another. Readers are strongly encouraged to understand the underlying concepts and adapt the guidance according to their own architecture, operational policies, and risk tolerance.
Blind copy-paste implementation without proper validation, testing, and change management is never recommended , especially in production environments. Always ensure proper backups and risk assessment before applying any configuration.
The content shared here is based on hands-on experience from real-world deployments, ISP environments, lab testing, and continuous learning. While I strive for technical accuracy, no technical implementation is entirely free from the possibility of error. Constructive discussion and alternative approaches are always welcome.
Due to professional commitments, it is not always feasible to publish highly detailed or multi-part write-ups. The technical logic and implementation details are written based on my own practical experience. AI tools such as ChatGPT are used only to refine grammar, structure, and presentation , not to generate the core technical concepts.
This blog is not intended for client acquisition or follower growth. It exists solely to share practical knowledge and real-world experience with the community.
Thank you for your understanding and continued support.
Table of Contents
1. Introduction
2. Why Redis Caching for FreeRADIUS
3. Authentication Flow Overview
4. Test Environment
5. Installing Redis on Ubuntu
6. Enabling Redis Module in FreeRADIUS
7. Editing FreeRADIUS Configuration
8. Authorize Section (Redis Lookup Logic)
9. Positive Cache – Valid User Handling
10. Negative Cache – Invalid User Handling
11. Testing Authentication with radtest
12. Verifying Redis Cache
13. Recommended TTL Values for Production
14. Monitoring Redis Activity
15. Cache Invalidation (Must Read, what happens when user package changes at backend)
16. Troubleshooting
17. Real ISP Scenario – Reconnect Storm Handling
18. Performance Impact
19. Conclusion
Introduction
FreeRADIUS is widely used as an AAA (Authentication, Authorization, Accounting) server in ISP and enterprise environments. In typical deployments, authentication requests are validated against a backend SQL database such as MySQL or MariaDB using the rlm_sql module.
While this architecture works well for moderate authentication loads, large networks, especially PPPoE based ISP infrastructures, can experience extremely high authentication rates during certain events such as:
- Power restoration events causing mass PPPoE reconnect storms
- Large numbers of CPE devices rebooting simultaneously
- Misconfigured or rogue ONU/ONT devices generating repeated login attempts
- Brute-force authentication attempts against RADIUS
In a traditional FreeRADIUS + SQL design, every authentication request results in multiple database queries (radcheck, radreply, radgroupcheck, radgroupreply, etc.). During reconnect storms involving thousands of subscribers, this can rapidly overload the database server, leading to increased authentication latency or even service disruption.
One effective way to mitigate this problem is to introduce a high-speed caching layer between FreeRADIUS and the SQL backend.
Redis, an in-memory key-value datastore, is well suited for this purpose due to its extremely low latency and high throughput. By caching authentication results in Redis, FreeRADIUS can avoid repeated SQL lookups for frequently authenticating users.
In this guide we implement two types of authentication caching:
Positive Cache
Successfully authenticated users are stored in Redis so subsequent authentication requests can be served directly from cache without querying SQL.
Negative Cache
Invalid usernames are temporarily cached to prevent repeated SQL lookups caused by brute-force attempts or misconfigured client devices.
This architecture significantly reduces database load while maintaining fast authentication response times. In large ISP environments this technique can reduce SQL queries by 80–95% during peak authentication events.
The following sections demonstrate how to integrate Redis with FreeRADIUS and implement both positive and negative authentication caching.
Redis acts as an ultra-fast in-memory cache in front of SQL and dramatically reduces database load. This caching model is commonly used by large WISP and ISP deployments to stabilize RADIUS infrastructure and protect backend databases from authentication storms.
Who This Guide Is For
This guide is intended for:
- ISP network engineers operating PPPoE/IPoE subscriber networks
- FreeRADIUS administrators managing large authentication loads
- Operators using MikroTik, Cisco, Juniper or similar NAS devices
- Engineers looking to reduce SQL load during authentication storms
Why Redis Caching for FreeRADIUS
Benefits of Redis caching include:
- Sub-millisecond authentication response
- Reduced SQL queries
- Protection against brute-force attempts
- Protection against broken CPE reconnect loops
- Improved scalability for large ISP networks
Authentication Flow Overview:

Prerequisites
Before implementing this configuration ensure:
- FreeRADIUS is already installed and running
- SQL module is working (MySQL / MariaDB)
- NAS device (e.g., MikroTik) is configured for RADIUS authentication
- Redis service is reachable/working from or at the RADIUS server
- Basic understanding of FreeRADIUS unlang configuration
Test Environment
Example lab setup used in this guide:
- OS: Ubuntu 22.04
- FreeRADIUS: 3.2.x
- Redis: 6.x
- Database: MySQL / MariaDB
- Client: radtest
- NAS: MikroTik RouterOS
Installing Redis on Ubuntu
Install Redis server:
sudo apt update sudo apt install redis-server
Enable Redis at boot:
sudo systemctl enable redis sudo systemctl start redis
Verify Redis is running:
redis-cli ping
Output:
PONG
Enabling Redis Module in FreeRADIUS
Edit the Redis module:
/etc/freeradius/mods-available/redis
Example minimal configuration:
redis {
server = "127.0.0.1"
port = 6379
database = 0
timeout = 5
# Connection pool settings (safe defaults)
pool {
start = 5
min = 5
max = 10
spare = 3
uses = 0
lifetime = 0
cleanup_interval = 30
}
}
Add Redis module loading:
Enable the module:
ln -s /etc/freeradius/mods-available/redis /etc/freeradius/mods-enabled/
Restart FreeRADIUS:
systemctl restart freeradius
Editing FreeRADIUS Configuration
The main configuration changes are made in:
/etc/freeradius/sites-enabled/default
Example working configuration used in this guide:
Authorize Section
This section performs Redis lookups before SQL.
authorize {
update control {
Tmp-String-1 := "%{tolower:%{User-Name}}"
}
# ==================== GENERIC CACHE HIT (ROBUST VERSION) ====================
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}" == "1") {
update control {
Auth-Type := ACCEPT
Tmp-String-0 := "cache-hit"
}
# Only set attributes that actually exist in Redis (prevents NIL error)
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-IP-Address}" == "1") {
update reply {
Framed-IP-Address := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Framed-IP-Address}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Pool}" == "1") {
update reply {
Framed-Pool := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Framed-Pool}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Route}" == "1") {
update reply {
Framed-Route := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Framed-Route}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Service-Type}" == "1") {
update reply {
Service-Type := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Service-Type}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Session-Timeout}" == "1") {
update reply {
Session-Timeout := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Session-Timeout}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Idle-Timeout}" == "1") {
update reply {
Idle-Timeout := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Idle-Timeout}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Class}" == "1") {
update reply {
Class := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Class}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Filter-Id}" == "1") {
update reply {
Filter-Id := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Filter-Id}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval}" == "1") {
update reply {
Acct-Interim-Interval := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Reply-Message}" == "1") {
update reply {
Reply-Message := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Reply-Message}"
}
}
# Vendor-specific (add more the same way)
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}" == "1") {
update reply {
Mikrotik-Rate-Limit := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List}" == "1") {
update reply {
Mikrotik-Address-List := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List}"
}
}
if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Cisco-AVPair}" == "1") {
update reply {
Cisco-AVPair := "%{redis:HGET fr:reply:%{control:Tmp-String-1} Cisco-AVPair}"
}
}
return
}
# ==================== NEGATIVE CACHE ====================
if ("%{redis:EXISTS fr:authfail:%{control:Tmp-String-1}}" == "1") {
update reply {
Reply-Message := "Too many failed attempts - try again later"
}
reject
}
# === Normal flow ===
preprocess
filter_username
suffix
sql
pap
chap
mschap
digest
expiration
}
POST-AUTH Section:
post-auth {
# Only cache real SQL replies (skip if it was already a cache hit)
if (!&control:Tmp-String-0 && &reply) {
# Status flag
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} status 1}"
}
# Standard attributes
if (&reply:Framed-IP-Address) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Framed-IP-Address %{reply:Framed-IP-Address}}"
}
}
if (&reply:Framed-Pool) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Framed-Pool %{reply:Framed-Pool}}"
}
}
if (&reply:Framed-Route) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Framed-Route %{reply:Framed-Route}}"
}
}
if (&reply:Service-Type) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Service-Type %{reply:Service-Type}}"
}
}
if (&reply:Session-Timeout) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Session-Timeout %{reply:Session-Timeout}}"
}
}
if (&reply:Idle-Timeout) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Idle-Timeout %{reply:Idle-Timeout}}"
}
}
if (&reply:Class) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Class %{reply:Class}}"
}
}
if (&reply:Filter-Id) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Filter-Id %{reply:Filter-Id}}"
}
}
if (&reply:Acct-Interim-Interval) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval %{reply:Acct-Interim-Interval}}"
}
}
if (&reply:Reply-Message) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Reply-Message %{reply:Reply-Message}}"
}
}
# MikroTik (add more vendors the same way)
if (&reply:Mikrotik-Rate-Limit) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit %{reply:Mikrotik-Rate-Limit}}"
}
}
if (&reply:Mikrotik-Address-List) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List %{reply:Mikrotik-Address-List}}"
}
}
# Cisco
if (&reply:Cisco-AVPair) {
update control {
Tmp-String-2 := "%{redis:HSET fr:reply:%{control:Tmp-String-1} Cisco-AVPair %{reply:Cisco-AVPair}}"
}
}
# Expire the whole HASH (1 hour + random jitter)
update control {
Tmp-String-2 := "%{redis:EXPIRE fr:reply:%{control:Tmp-String-1} %{expr:3600 + %{rand:0..300}}}"
}
}
}
Post-Auth-Type Reject {
"%{redis:SET fr:authfail:%{control:Tmp-String-1} 1}"
"%{redis:EXPIRE fr:authfail:%{control:Tmp-String-1} 90}"
}
pre-proxy {
}
post-proxy {
}
}
#}
Now repeated invalid attempts are immediately rejected without touching SQL.
NOTE: We use HEXISTS before every HGET so we never hit Redis NIL values — this makes the cache work perfectly for users who have only a few reply attributes (very common in real ISPs).
Testing Authentication
Use radtest:
Start FREERADIUS in debug mode
freeradius -X
This runs FreeRADIUS in debug mode and shows detailed authentication flow.
Now from another console session issue below cmds
- (check debug log for 1st request, and then for 2nd request, you will see the difference that in 1st request SQL is always used, but for 2nd request for same user, REDIS is used and no SQL involved in there)
radtest username password localhost 0 testing123
Example valid login:
radtest zaib zaib 127.0.0.1 0 testing123
Example invalid login:
radtest fakeuser password 127.0.0.1 0 testing123
- Example of request served by redis CACHE
(6) Received Access-Request Id 29 from 127.0.0.1:59048 to 127.0.0.1:1812 length 62
(6) Message-Authenticator = 0x500bbca2dce98e71bde05558d35540bc
(6) User-Name = "zaib"
(6) User-Password = "zaib"
(6) # Executing section authorize from file /etc/freeradius/sites-enabled/default
(6) authorize {
(6) update control {
(6) EXPAND %{tolower:%{User-Name}}
(6) --> zaib
(6) Tmp-String-1 := zaib
(6) } # update control = noop
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib status"
rlm_redis (redis): Released connection (0)
Need 3 more connections to reach min connections (5)
Need more connections to reach 3 spares
rlm_redis (redis): Opening additional connection (6), 1 of 8 pending slots used
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}
(6) --> 1
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}" == "1") -> TRUE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}" == "1") {
(6) update control {
(6) Auth-Type := Accept
(6) Tmp-String-0 := "cache-hit"
(6) } # update control = noop
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-IP-Address}" == "1") {
rlm_redis (redis): Reserved connection (5)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Framed-IP-Address"
rlm_redis (redis): Released connection (5)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-IP-Address}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-IP-Address}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Pool}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Framed-Pool"
rlm_redis (redis): Released connection (0)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Pool}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Pool}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Route}" == "1") {
rlm_redis (redis): Reserved connection (6)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Framed-Route"
rlm_redis (redis): Released connection (6)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Route}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Framed-Route}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Service-Type}" == "1") {
rlm_redis (redis): Reserved connection (5)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Service-Type"
rlm_redis (redis): Released connection (5)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Service-Type}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Service-Type}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Session-Timeout}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Session-Timeout"
rlm_redis (redis): Released connection (0)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Session-Timeout}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Session-Timeout}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Idle-Timeout}" == "1") {
rlm_redis (redis): Reserved connection (6)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Idle-Timeout"
rlm_redis (redis): Released connection (6)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Idle-Timeout}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Idle-Timeout}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Class}" == "1") {
rlm_redis (redis): Reserved connection (5)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Class"
rlm_redis (redis): Released connection (5)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Class}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Class}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Filter-Id}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Filter-Id"
rlm_redis (redis): Released connection (0)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Filter-Id}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Filter-Id}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval}" == "1") {
rlm_redis (redis): Reserved connection (6)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Acct-Interim-Interval"
rlm_redis (redis): Released connection (6)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Acct-Interim-Interval}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Reply-Message}" == "1") {
rlm_redis (redis): Reserved connection (5)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Reply-Message"
rlm_redis (redis): Released connection (5)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Reply-Message}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Reply-Message}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Mikrotik-Rate-Limit"
rlm_redis (redis): Released connection (0)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}
(6) --> 1
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}" == "1") -> TRUE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}" == "1") {
(6) update reply {
rlm_redis (redis): Reserved connection (6)
rlm_redis (redis): executing the query: "HGET fr:reply:zaib Mikrotik-Rate-Limit"
rlm_redis (redis): Released connection (6)
(6) EXPAND %{redis:HGET fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}
(6) --> 10M/10M
(6) Mikrotik-Rate-Limit := 10M/10M
(6) } # update reply = noop
(6) } # if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Rate-Limit}" == "1") = noop
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List}" == "1") {
rlm_redis (redis): Reserved connection (5)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Mikrotik-Address-List"
rlm_redis (redis): Released connection (5)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Mikrotik-Address-List}" == "1") -> FALSE
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Cisco-AVPair}" == "1") {
rlm_redis (redis): Reserved connection (0)
rlm_redis (redis): executing the query: "HEXISTS fr:reply:zaib Cisco-AVPair"
rlm_redis (redis): Released connection (0)
(6) EXPAND %{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Cisco-AVPair}
(6) --> 0
(6) if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} Cisco-AVPair}" == "1") -> FALSE
(6) return
(6) } # if ("%{redis:HEXISTS fr:reply:%{control:Tmp-String-1} status}" == "1") = noop
(6) } # authorize = noop
(6) Found Auth-Type = Accept
(6) Auth-Type = Accept, accepting the user
(6) # Executing section post-auth from file /etc/freeradius/sites-enabled/default
(6) post-auth {
(6) if (!&control:Tmp-String-0 && &reply) {
(6) if (!&control:Tmp-String-0 && &reply) -> FALSE
(6) } # post-auth = noop
(6) Sent Access-Accept Id 29 from 127.0.0.1:1812 to 127.0.0.1:59048 length 53
(6) Mikrotik-Rate-Limit := "10M/10M"
(6) Finished request
- NO SQL INVOLED 🙂
Verifying Redis Cache
View cached users:
redis-cli KEYS authcache:*
Example:
authcache:zaib authcache:zaib:rate
View failed users:
redis-cli KEYS authfail:*
Example:
authfail:testuser
Check TTL:
redis-cli TTL authcache:zaib
Monitoring Redis Activity
Watch live Redis commands:
redis-cli MONITOR
Useful for debugging FreeRADIUS caching behavior.
Important: Cache Invalidation (Must Read)
Cache Invalidation Strategy (Very Important) Important Note:
The biggest challenge with caching is stale data. If you disable a user, change package, change password, or suspend them, the old cached data will remain until TTL expires.
Best Practice: Clear the cache immediately after any change in your billing system.
Example scenario:
- User upgraded from 10M → 20M package.
Redis may still return:
- Mikrotik-Rate-Limit = 10M/10M
Until the cache expires.
What happens right now in our config:
- User connects → cache hit → we return old reply attributes without touching SQL at all.
- You disable user / change password / change package in database → cache still has old data.
- User reconnects (or you CoA-disconnect him) → still served from Redis → old speed / still active / old password accepted.
So yes, the user will stay online with old settings until the 1-hour TTL expires. This is normal behavior of any cache — we traded freshness for speed.
Best solution: Purge cache when user changes
The correct solution used by most WISPs is:
Whenever a user is modified in billing / CRM, delete their Redis cache entry.
Example:
DEL fr:reply:username
Then the next login must go to SQL.
Complete Workflow (Best Practice)
- Change user data in billing system
- Clear Redis cache
- Send CoA-Disconnect
- User reconnects → gets fresh data from SQL
This gives you both high performance and instant consistency.
Example automation
When you change a package or password:
Recommended Solution: Invalidate Cache from Your Billing System (Instant). Whenever you do any of these actions, immediately delete the Redis key:
Bash
redis-cli DEL fr:reply:zaib redis-cli DEL fr:authfail:zaib # optional but good
Bash one-liner you can call from any script:
redis-cli -a YourVeryStrongRedisPassword123! DEL fr:reply:$USERNAME fr:authfail:$USERNAME
Pro tip: Also send CoA (Change of Authorization) at the same time so the user is immediately disconnected and forced to re-auth with new settings.
This is how real ISP systems work
Typical architecture:
Billing System
│
├── SQL update
│
└── Redis purge
Example:
- User disabled
- Password change
- Package change
All trigger:
DEL fr:reply:user
Troubleshooting Section
Check Redis keys
redis-cli KEYS authcache:* redis-cli KEYS authfail:*
Check TTL
redis-cli TTL authcache:username
Monitor Redis activity
redis-cli MONITOR
Run FreeRADIUS debug
freeradius -X
Recommended TTL for Production
For ISP environments the following TTL values are commonly used.
Valid user cache:
- 3600 seconds (1 hour)
Invalid user cache:
- 60–120 seconds
Reasons:
- Reduces SQL load significantly
- Protects against brute-force attacks
- Allows quick recovery if a new user is added
Where to Modify TTL
File
/etc/freeradius/sites-enabled/default
Valid User Cache TTL
Current:
"%{redis:EXPIRE authcache:%{control:Tmp-String-1} 3600}"
"%{redis:EXPIRE authcache:%{control:Tmp-String-1}:rate 3600}"
- 3600 = 1 hour
Example changes:
Example change:
"%{redis:EXPIRE authcache:%{control:Tmp-String-1} 1800}"
"%{redis:EXPIRE authcache:%{control:Tmp-String-1}:rate 1800}"
Now valid users will stay cached for 30 minutes.
Invalid User Cache TTL
Current:
"%{redis:EXPIRE authfail:%{control:Tmp-String-1} 60}"
- 60 = 1 minute
Example change:
"%{redis:EXPIRE authfail:%{control:Tmp-String-1} 300}"
Now invalid usernames will be cached for 5 minutes.
Check TTL in Redis
You can verify TTL with:
redis-cli TTL authcache:zaib
Example output:
3450
Means 3450 seconds remaining.
Our caching architecture is actually very close to what large WISPs use, especially with:
- positive cache
- negative cache
- SQL fallback
Real ISP Scenario
Consider a city power outage where thousands of PPPoE customers reconnect simultaneously.
Without Redis caching:
10,000 users reconnect
→ 10,000 SQL queries
→ MySQL CPU spike
→ Authentication delays
With Redis caching:
- Most users already exist in Redis cache.
Result:
- Redis serves authentication instantly
- SQL load remains minimal
- Authentication remains stable
Performance Impact
Typical improvements seen in ISP deployments:
Redis allows FreeRADIUS to handle significantly more authentication requests without database bottlenecks.
Handling PPPoE Reconnect Storms
Explain:
When power returns in a city or building, thousands of PPPoE clients reconnect simultaneously.
Without Redis:
- 10,000 users reconnect
- → 10,000 SQL queries instantly
- → MySQL overload
With Redis:
- Most users already cached
- → Redis serves authentication
- → SQL load minimal
Key Takeaways
✔ Redis dramatically reduces SQL authentication load
✔ Positive cache serves valid users instantly
✔ Negative cache blocks repeated invalid logins
✔ Simple integration with FreeRADIUS using rlm_redis
✔ Highly effective in PPPoE ISP environments
Conclusion:
Redis caching significantly improves FreeRADIUS performance and stability.
By implementing:
- Positive authentication cache
- Negative authentication cache
You can drastically reduce SQL load and protect your RADIUS infrastructure from authentication storms and malicious traffic. This architecture is commonly used by ISPs running large PPPoE deployments with tens of thousands of subscribers. This Redis caching architecture is widely used by large WISP and ISP deployments to protect RADIUS infrastructure from authentication storms and reduce backend database load.
— Syed Jahanzaib
5th March 2026



