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

March 2, 2026

FreeRADIUS Group Policy Design: Service Assignment Using radcheck, radgroupreply, and radusergroup


 


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


Introduction

Intended Audience:
This guide is intended for:

  • ISP network engineers managing PPPoE/IPoE subscribers
  • Operators using NAS with FreeRADIUS
  • System administrators responsible for subscriber policy control
  • ISPs planning to scale beyond manual user-based configurations

It is especially useful for teams that:

  • ✔ Want centralized package management
  • ✔ Need scalable service provisioning
  • ✔ Are integrating RADIUS with billing systems
  • ✔ Plan to introduce time-based or promotional packages

Prerequisites
Before implementing this design, ensure the following are in place:

  • FreeRADIUS installed with SQL module enabled
  • MikroTik configured as NAS
  • PPPoE authentication integrated with RADIUS
  • SQL database connected to FreeRADIUS
  • Accounting enabled

This guide assumes a working FreeRADIUS + MikroTik integration.


Table of Contents

  1. Introduction
  2. Who This Guide Is For
  3. Prerequisites
  4. Understanding the Core FreeRADIUS Tables
  5. How FreeRADIUS Makes Decisions (Processing Order)
  6. Step 1 – Creating the User (radcheck)
  7. Step 2 – Creating a Service Package (radgroupreply)
  8. Step 3 – Assigning Package to User (radusergroup)
  9. Step 4 – Adding Package Restrictions (radgroupcheck)
  10. How the Policy Flow Works in Real ISP Environment
  11. Role of MikroTik in Service Enforcement
  12. Testing the Configuration using radclient
  13. Scaling the Design for Large Subscriber Base
  14. Understanding radreply and Its Role
  15. Why radreply Should Not Be Used for Standard ISP Packages
  16. Group-Based Architecture vs Per-User Model
  17. When radreply Should Be Used
  18. Operational Benefits for ISPs
  19. Best Practice Architecture Summary

Whether you’re running a small community network or a growing regional ISP, this approach helps build a structured and manageable subscriber policy framework. This becomes especially important when managing hundreds or thousands of subscribers.

In ISP environments, managing subscriber services efficiently is just as important as delivering bandwidth. As networks grow from a few dozen users to hundreds or thousands of subscribers, manually assigning policies per user quickly becomes unsustainable.

FreeRADIUS provides a scalable way to design and manage subscriber packages using group-based policy architecture. Instead of defining speed, limits, or access rules individually for each user, ISPs can:

  • Create service packages
  • Assign users to those packages
  • Centrally control policy changes

This approach simplifies:

  • ✔ Package upgrades
  • ✔ Seasonal offers
  • ✔ Bulk migrations
  • ✔ Billing integration

In this post, we’ll walk through how FreeRADIUS group policy design works using:

  • Radcheck
  • Radusergroup
  • Radgroupreply

But one of the most confusing parts for many operators is:

  • 👉 Where do we create the user?
  • 👉 Where do we define the package?
  • 👉 Where do we assign the speed?

Many people mistakenly put everything inside one table… and later things become messy when customers increase. Today we’ll simplify this using a real-world ISP scenario.

Let’s say:

  • You want to create a 10 Mbps internet package
  • And assign it to a user named zaib

Understanding the Core FreeRADIUS Tables:

How FreeRADIUS Makes Decisions (Processing Order)

FreeRADIUS does not randomly apply policies. It processes SQL data in a defined order. Before creating users or packages, it’s important to understand how FreeRADIUS evaluates policies internally. FreeRADIUS processes SQL tables in the following order: To understand how packages work, first understand how FreeRADIUS evaluates users. FreeRADIUS SQL works like a proper ISP business model. Each table has a separate role, just like departments in a company.

How All Tables Work Together

In a typical authentication flow, these SQL tables operate as a layered policy engine rather than isolated components.

When a user attempts to log in, FreeRADIUS first evaluates radcheck for user-specific validation rules such as credentials, expiration settings, or account-level restrictions. If authentication succeeds, the system then checks radreply for any user-specific reply attributes that should be returned directly to the NAS (for example, a custom bandwidth override or a temporary service adjustment).

Next, FreeRADIUS consults radusergroup to determine which service group(s) the user belongs to. Based on that membership, radgroupcheck enforces group-level conditions such as Simultaneous-Use limits, time restrictions, or other policy constraints. If all validation checks pass, the corresponding service attributes from radgroupreply are loaded and returned to the NAS, typically defining bandwidth profiles, VLAN assignments, session parameters, or other package-level settings.

This layered structure separates authentication, user-level overrides, group-based validation, and service delivery into distinct logical stages. The result is a modular and scalable design where:

  • radcheck → validates the user
  • radreply → applies user-specific service overrides
  • radusergroup → maps the user to a package
  • radgroupcheck → enforces package restrictions
  • radgroupreply → defines package services

This separation of concerns makes the system flexible and maintainable, especially in ISP environments where thousands of subscribers share common packages but still require granular per-user control when needed.

This order becomes critical when both user-level and group-level policies exist. This order is important because it explains why group-based design is scalable and predictable.


Step 1: Create the User (radcheck):

radcheck — “User-Specific Authentication & Control Rules”

The radcheck table stores per-user validation conditions that must be satisfied during authentication. Unlike radgroupcheck, which applies rules at the group level, radcheck applies checks directly to an individual username.
This table is commonly used to define authentication credentials (such as Cleartext-Password), account expiration dates, login time restrictions, or other user-specific control attributes.
In ISP deployments, radcheck typically contains the subscriber’s password and any individual-level overrides — for example, disabling a single account, applying a temporary restriction, or setting a custom limit that differs from the assigned package.

This table is only for login authentication.  No speed here. No service here.
Just:

✔ Username
✔ Password

Example:
User:
zaib / abc123

INSERT INTO radcheck (username, attribute, op, value)
VALUES ('zaib', 'Cleartext-Password', ':=', 'abc123');

Now user exists in system ✔

Step 2: Create the 10MB Package (radgroupreply):

radgroupreply — “Service Policy Definitions”

This table defines the service attributes that FreeRADIUS will return to the NAS when a user belongs to a particular group.
Think of radgroupreply as the place where you declare what services a group should receive — for example, rate limits, session times, VLAN attributes, or any other RADIUS reply attributes.
When a user authenticates and is mapped to a group, all matching entries in radgroupreply are sent as reply attributes. For ISPs, this is typically where you define customer package characteristics (e.g., upload/download speeds, bandwidth policy strings, or other NAS-specific flags).

Let’s create:

Package name = 10MB

INSERT INTO radgroupreply (groupname, attribute, op, value)
VALUES ('10MB', 'Mikrotik-Rate-Limit', ':=', '10M/10M');

Now:

Group 10MB = 10 Mbps service

Step 3: Assign Package to User (radusergroup):

radusergroup — “User ↔ Package Mapping”

This table links users to groups (packages) and controls which service profile applies to a subscriber.
Instead of assigning reply attributes individually per user, radusergroup lets you assign one or more groups (or packages) to a username. It also supports a priority field, which determines the order in which packages should be evaluated when multiple memberships exist.
In ISP context, this is where you map a subscriber (zaib, user123) to a tariff plan such as “10MB”, “Night-Unlimited”, or other service offerings. This mapping makes large-scale provisioning and upgrades far easier.

Now we “subscribe” user zaib to this package.

INSERT INTO radusergroup (username, groupname, priority)
VALUES ('zaib', '10MB', 1);

That’s it. No need to touch speed again.

Note on Priority:
The priority field controls which group is evaluated first. This becomes important when a user belongs to multiple groups
(for example: Base plan + Night package).
Lower number = higher priority.

Step 4: Optional Restrictions (radgroupcheck):

radgroupcheck — “Group-Based Conditions / Restrictions”

This table is used to define validation checks and conditions on a service group.
While radgroupreply defines what to give, radgroupcheck defines what to check before assigning the group’s services — for example, simultaneous use limits, time restrictions (e.g., only allow login between certain hours), expiration settings, or NAS filtering conditions.
For ISPs, this is where you enforce business logic like “only 1 session at a time” (Simultaneous-Use), “access allowed only at night,” or other policy limits that depend on subscriber behavior or timing.

Real ISP Example of radgroupcheck

In real ISP deployments, radgroupcheck is typically used for:

  • Night packagesWeekend plans
  • Ramadan offers
  • Session limits
  • Access control per NAS

Example: Prevent account sharing

INSERT INTO radgroupcheck (groupname, attribute, op, value)
VALUES ('10MB', 'Simultaneous-Use', ':=', '1');

Example: Night-only package

INSERT INTO radgroupcheck (groupname, attribute, op, value)
VALUES ('NIGHT-10MB', 'Login-Time', ':=', 'Al0100-0800');

How It Works in Real ISP Flow:

When zaib logs into PPPoE, this happens:

🔍Authentication Check

FreeRADIUS checks:

👉 radcheck
Is password correct?

✔ Yes → continue

📦 Package Lookup

FreeRADIUS checks:

👉 radusergroup
Which package?

→ 10MB

🚀 Service Applied

FreeRADIUS checks:

👉 radgroupreply

Finds:

→ 10M/10M

Sends to MikroTik.

MikroTik applies speed.

Done ✔

Role of MikroTik in This Design:

FreeRADIUS does not enforce speed. It only sends policy attributes.
When FreeRADIUS sends:

  • Mikrotik-Rate-Limit = 10M/10M

MikroTik dynamically creates a simple queue for the PPP session based on the RADIUS reply.

So:

  • FreeRADIUS = Policy Brain
  • MikroTik = Enforcement Engine

Visual Flow:

Subscriber authentication and policy enforcement flow in a MikroTik + FreeRADIUS ISP deployment


Final Result:

All centrally managed.


Testing via RADCLIENT:

To verify group assignment during live login:

Run:

freeradius -X

You should see:

Found group 10MB
Mikrotik-Rate-Limit := 10M/10M

root@radius:~# echo "User-Name=zaib,User-Password=abc123" | radclient -x localhost:1812 auth testing123
Sent Access-Request Id 112 from 0.0.0.0:58903 to 127.0.0.1:1812 length 62
Message-Authenticator = 0x
User-Name = "zaib"
User-Password = "abc123"
Cleartext-Password = "abc123"
Received Access-Accept Id 112 from 127.0.0.1:1812 to 127.0.0.1:58903 length 53
Message-Authenticator = 0xb8ae569d527842c659e6be96831fccc6
Mikrotik-Rate-Limit = "10M/10M"

Scaling Example

Let’s assume:
500 users belong to the 10MB package.
To upgrade:
10MB → 15MB

You update only:
radgroupreply
Instead of modifying 500 individual user entries.

In large ISP environments, this enables bulk upgrades without touching individual subscriber records. This becomes critical during:

  • Bandwidth revisions
  • Promotional upgrades
  • Seasonal packages

In real ISP environments, users often belong to multiple groups
(e.g. Base Plan + Night Plan).

Group priority determines which policy is applied first. This is the operational advantage of group-based design.


Understanding radreply and Why ISPs Should Avoid Using It for Services:

So far we covered:

But there is one more table that often creates confusion:

👉 radreply
(👉 radreply is processed before groups)

So…

  • 👉 What is radreply
  • 👉 When to use it
  • 👉 Why NOT to use it for packages in large ISPs

What is radreply?

radreply is used to assign reply attributes directly to a user. These attributes are processed before group-level policies.

Meaning:

Instead of assigning service via group… You attach service directly to username.

Example:

INSERT INTO radreply (username, attribute, op, value)
VALUES ('zaib', 'Mikrotik-Rate-Limit', ':=', '10M/10M');

Now:

User zaib gets 10Mbps > directly. No group involved.

Important Behavior of radreply:

FreeRADIUS evaluates radreply before group policies. This means per-user attributes may override group policies. This means that if the same attribute exists in both radreply and radgroupreply, the user-level value will take precedence. This is a common cause of speed mismatch issues in production ISP deployments.

If the same attribute exists in both:

  • radreply
  • radgroupreply

Then radreply may override the group setting. This can result in:

  • Inconsistent speeds
  • Debugging complexity

Which is why ISPs avoid using radreply for standard packages.

Why This is Bad for ISPs at Scale:

This works fine when you have:

  • ✔ 10 users
  • ✔ 20 users

But becomes a disaster when you have:

  • ❌ 500 users
  • ❌ 2000 users

Because now:
Each user carries service logic.

Real ISP Problem:

Let’s say:
You want to upgrade:

All 10MB users → 15MB

If you used radreply:

You must update every user row individually.

Nightmare 😅

radreply = Per User Logic

This is:

Group-Based Model = Scalable:

Correct ISP design is:

User → radusergroup → Group → radgroupreply → Service

Instead of:

User → radreply → Service

radreply vs Group Model

Service logic location in RADIUS architecture

Real-World Analogy:

Think like a cable operator in Karachi.

radreply model:

Every customer has a custom package manually written.

Group model:

You create packages like:

  • 10MB
  • 20MB
  • Night Unlimited

And assign users to them. Much cleaner ✔

Operational Impact:

Best Practice for Large ISPs:

Use:

Avoid putting:

  • ❌ Speed
  • ❌ Time
  • ❌ FUP
  • ❌ Suspension

inside radreply

When Should You Use radreply?

radreply is best suited for exceptions, not standard packages.
Valid use cases:

  • Static IP assignment
  • VIP customers
  • Enterprise custom policies

Example:

  • Framed-IP-Address := 203.x.x.x

Avoid using radreply for:

  • Speed plans
  • FUP policies
  • Time-based packages

Final Recommendation

Operational Benefits for ISPs:

Group-based design enables:

✔ Bulk upgrades
✔ Seasonal packages (Ramadan / Night)
✔ Billing integration
✔ Faster provisioning

Without editing individual users.

For growing ISPs:

👉 radreply = individual customization
👉 radgroupreply = business packages

And scalable networks always use packages.


Real-World Benefit for ISPs:

This model helps when:

✔ Customers upgrade frequently
✔ Seasonal packages launched
✔ Night / Ramadan offers needed
✔ Integration with billing required

Instead of manually editing users (which becomes impossible after 500+ customers), you manage services professionally.


Best Practice Summary:


Coming Next:

In upcoming posts, we’ll cover:

👉 Night packages
👉 Ramadan offers
👉 FUP (data limit plans)
👉 Speed boost systems

All using MikroTik + FreeRADIUS.