Syed Jahanzaib – سید جہانزیب – Personal Blog to Share Knowledge !

March 5, 2026

Optimizing FreeRADIUS with Redis Authentication Cache (Complete Guide)


  • 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:

  1. User connects → cache hit → we return old reply attributes without touching SQL at all.
  2. You disable user / change password / change package in database → cache still has old data.
  3. 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