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

February 7, 2026

Building a Production-Grade Prepaid Time System in FreeRADIUS 3.x (Lessons Learned the Hard Way)

Filed under: freeradius, Linux Related — Tags: , , , , , , , , — Syed Jahanzaib / Pinochio~:) @ 6:54 PM

 

  • Author: Syed Jahanzaib ~A Humble Human being! nothing else 😊
  • Platform: aacable.wordpress.com
  • Category: Corporate Offices / DHCP-DNS Engineering
  • Audience: Systems Administrators, IT Support, NOC Teams, Network Architects

⚠️ 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.


Building a Production-Grade Prepaid Time System in FreeRADIUS 3.2.x

(Lessons Learned by zaib , the Hard Way)

Introduction

Implementing prepaid time-based accounts in FreeRADIUS sounds simple at first:

“Just create a 1-hour card and expire it when time is used.”

In reality, if you want a correct, scalable, and production-safe solution, you will quickly discover that:

  • Max-All-Session does not reject logins
  • Expiration does not behave the way most people assume
  • unlang math is fragile and error-prone
  • FreeRADIUS has very strict parsing and execution rules
  • The correct solution is not obvious unless you understand internals

This article documents a real-world journey of building a prepaid voucher system in FreeRADIUS 3.x, including every pitfall, diagnostic message, and final correct design.

If you are planning:

  • 1-hour / 1-day / 1-week / 1-month cards
  • Countdown from first login
  • Pause/resume usage
  • Hard expiry safety
  • Clean rejection messages < This is where i stucked for hours

👉 This article will save you days of frustration.


Design Requirements

We wanted the system to behave as follows:

  • Prepaid cards with fixed time (e.g. 1 hour = 3600 seconds)
  • Time countdown starts only after first login
  • Time pauses when the user disconnects
  • Once time is fully consumed → login must be rejected
  • Optional hard expiry (e.g. 1 year safety)
  • Clear diagnostic messages:
    • Time quota exhausted
    • Account expired
    • Invalid username or password
  • Must scale (no heavy SQL in unlang)

What Does NOT Work (Common Mistakes)

Before the correct solution, let’s clear misconceptions.

Max-All-Session alone is NOT enough

Max-All-Session:

  • Limits session duration
  • Does not reject authentication
  • Only influences Session-Timeout

So even if a user has used all time, authentication can still succeed.

❌ Using Expiration for time quota

Expiration:

  • Is date-based only
  • Does not support HH:MM:SS
  • Is parsed by rlm_expiration
  • Midnight expiry is by design

It is not a time quota mechanism.

❌ Doing math in unlang

Examples like:

if (SUM(radacct) >= Max-All-Session)

lead to:

  • parser errors
  • performance issues
  • unreadable configs
  • upgrade nightmares

This approach is not production-safe.


The Correct Tool: rlm_sqlcounter

FreeRADIUS already ships with a module designed exactly for this problem:

rlm_sqlcounter

It:

  • Tracks usage via accounting
  • Compares usage against a limit
  • Rejects authentication automatically
  • Scales cleanly
  • Avoids unlang math entirely

This is how ISPs and hotspot providers do it.


Final Architecture (Correct & Supported)

Components

Component Purpose
radacct Stores used time
sqlcounter Enforces quota
Expiration Safety expiry
Post-Auth-Type REJECT Diagnostic messages

Step 1 – Prepaid Attribute in radcheck

Example: 1-hour card
(Expiration 1 year (for safe side so that these accounts may not remain live for ever)

INSERT INTO radcheck (username, attribute, op, value)
VALUES
('card1001', 'Cleartext-Password', ':=', 'card1001'),
('card1001', 'Prepaid-Time-Limit', ':=', '3600'),
('card1001', 'Expiration', ':=', '31 Jan 2027');

⚠️ Important

  • Do NOT define Prepaid-Time-Limit in any dictionary
  • sqlcounter registers it dynamically as integer64

Step 2 – sqlcounter Module (The Core)

Create the module:

/etc/freeradius/mods-available/sqlcounter_prepaid

sqlcounter prepaid_time {
sql_module_instance = sql
key = User-Name
counter_name = Prepaid-Time-Limit
check_name = Prepaid-Time-Limit
reply_name = Session-Timeout
reset = never
query = "SELECT COALESCE(SUM(acctsessiontime),0)
FROM radacct
WHERE username='%{User-Name}'"
}

Enable it:

ln -s /etc/freeradius/mods-available/sqlcounter_prepaid \
/etc/freeradius/mods-enabled/sqlcounter_prepaid

Step 3 – Call sqlcounter in Authorization

In/etc/freeradius/sites-enabled/default

authorize {
sql
expiration
prepaid_time ## This it the one , zaib
logintime
filter_username
preprocess
chap
mschap
digest
suffix
eap {
ok = return
}
files
pap
}

What happens now:

  • If used time < limit → Access-Accept
  • If used time ≥ limit → Access-Reject
  • No unlang math
  • No ambiguity

Step 4 – Correct Diagnostic Messages (This Is Where Most People Fail)

FreeRADIUS does not automatically tell users why they were rejected.
We must explicitly add logic in Post-Auth-Type REJECT.

⚠️ Important unlang rules

  • Attributes must be tested with &
  • if (control:Attribute) is invalid
  • Never compare Auth-Type to Reject
  • Order of conditions matters

Final, Correct Post-Auth-Type REJECT Block

post-auth {
Post-Auth-Type REJECT {
#
# 1) Prepaid quota exhausted (sqlcounter)
#
if (&control:Prepaid-Time-Limit) {
update reply {
Reply-Message := "Time quota exhausted"
}
}
#
# 2) Date-based expiration
#
elsif (&control:Expiration) {
update reply {
Reply-Message := "Account expired"
}
}
#
# 3) All other failures
#
else {
update reply {
Reply-Message := "Invalid username or password"
}
}
sql
attr_filter.access_reject
}
}

This produces clean, deterministic results.

FREERADIUS server reload

Note: After any change in the Freeradius CONFIG files, ensure to reload or restart freeradius service by

  • service freeradius reload 

Also its better to check freeradius config syntax before reload/restarting  by issuing below cmd

  • freeradius -XC

Final Behaviour (Verified with radclient)

Using RADCLIENT,

echo “User-Name=card1001,User-Password=card1001” | radclient -x localhost:1812 auth testing123

  • Quota exhausted

Access-Reject

Reply-Message = “Time quota exhausted

  • Date expired

Access-Reject

Reply-Message = “Account expired

  • Wrong credentials

Access-Reject

Reply-Message = “Invalid username or password


Optional: Show Used vs Allocated time (SQL)

Here is the final, clean, production-safe SQL query to show remaining prepaid time in a user-friendly HH:MM:SS format, based on everything we finalized.

This query is read-only, audit-safe, and does not interfere with sqlcounter enforcement.

SELECT
rc.username,
SEC_TO_TIME(MAX(CAST(rc.value AS UNSIGNED))) AS allocated_time,
SEC_TO_TIME(IFNULL(SUM(ra.acctsessiontime),0)) AS used_time,
SEC_TO_TIME(
GREATEST(
0,
MAX(CAST(rc.value AS UNSIGNED)) - IFNULL(SUM(ra.acctsessiontime),0)
)
) AS remaining_time
FROM radcheck rc
LEFT JOIN radacct ra
ON rc.username = ra.username
WHERE rc.username = 'card1001'
AND rc.attribute = 'Prepaid-Time-Limit'
GROUP BY rc.username;

🧪 Expected Output

+———-+—————+———–+—————-+
| username | allocated_time| used_time | remaining_time |
+———-+—————+———–+—————-+
| card1001 | 01:00:00 | 01:00:00 | 00:00:00 |
+———-+—————+———–+—————-+

🏁 Final Confirmation

This query is now:

✔ MySQL-8 compliant
ONLY_FULL_GROUP_BY safe
✔ Accurate
✔ Read-only
✔ Audit-friendly

You’re done — this is the final form of the remaining-time query.


Key Lessons Learned  the hard way !

by zaib,THE HARDWAY
(Read This Twice)

  1. Never fight FreeRADIUS design
  2. Max-All-Session ≠ quota enforcement
  3. Expiration ≠ time tracking
  4. unlang math is fragile — avoid it
  5. sqlcounter exists for a reason
  6. Attributes are tested with &
  7. Dictionary collisions break sqlcounter
  8. Diagnostic messages must be explicit
  9. Order of checks matters
  10. Debug (freeradius -X) is your best friend

Creating 1-Hour / 1-Day / 1-Week / 1-Month Prepaid Cards

Once the prepaid time system is implemented using rlm_sqlcounter, creating different card durations becomes purely a data task.
No configuration changes are required.

The only thing that changes is the time value (in seconds) stored in radcheck.

Always add Expiration (like 1 year or few months) (For safe side so that these accounts may not remain live for ever)


Time Conversion Reference

Plan Duration Seconds
1 Hour 1 × 60 × 60 3600
1 Day 24 × 60 × 60 86400
1 Week 7 × 24 × 60 × 60 604800
1 Month (30 days) 30 × 24 × 60 × 60 2592000

ℹ️ Note

  • A “month” is intentionally treated as 30 days for consistency and predictability.
  • Calendar months vary in length and should not be used for prepaid time accounting.

Example: Creating Prepaid Cards

1️⃣ 1-Hour Card

INSERT INTO radcheck (username, attribute, op, value)
VALUES
(‘card1001’, ‘Cleartext-Password’, ‘:=’, ‘card1001’),
(‘card1001’, ‘Prepaid-Time-Limit’, ‘:=’, ‘3600’),
(‘card1001’, ‘Expiration’, ‘:=’, ’31 Jan 2027′);

2️⃣ 1-Day Card

INSERT INTO radcheck (username, attribute, op, value)
VALUES
(‘card2001’, ‘Cleartext-Password’, ‘:=’, ‘card2001’),
(‘card2001’, ‘Prepaid-Time-Limit’, ‘:=’, ‘86400’),
(‘card2001’, ‘Expiration’, ‘:=’, ’31 Jan 2027′);

3️⃣ 1-Week Card

INSERT INTO radcheck (username, attribute, op, value)
VALUES
(‘card3001’, ‘Cleartext-Password’, ‘:=’, ‘card3001’),
(‘card3001’, ‘Prepaid-Time-Limit’, ‘:=’, ‘604800’),
(‘card3001’, ‘Expiration’, ‘:=’, ’31 Jan 2027′);

4️⃣ 1-Month Card (30 Days)

INSERT INTO radcheck (username, attribute, op, value)
VALUES
(‘card4001’, ‘Cleartext-Password’, ‘:=’, ‘card4001’),
(‘card4001’, ‘Prepaid-Time-Limit’, ‘:=’, ‘2592000’),
(‘card4001’, ‘Expiration’, ‘:=’, ’31 Jan 2027′);

Why This Design Works Perfectly

✔ Countdown starts from first login
✔ Time pauses when the user disconnects
✔ Time resumes on next login
✔ Authentication is rejected immediately when quota is exhausted
Expiration provides a hard safety cutoff
✔ No unlang math
✔ No schema changes
✔ Scales cleanly

All cards — hourly, daily, weekly, monthly — are handled by the same logic.


Operational Tip (Recommended)

For large deployments:

  • Generate cards in batches
  • Store card type in an external inventory table
  • Keep FreeRADIUS focused only on authentication & accounting

Example naming convention:

H-XXXX → Hourly cards
D-XXXX → Daily cards
W-XXXX → Weekly cards
M-XXXX → Monthly cards

Final Note

At this point, your FreeRADIUS setup supports:

  • Prepaid vouchers
  • Flexible durations
  • Clean enforcement
  • Clear diagnostics
  • Enterprise-grade behavior

No further complexity is required.


Final Thoughts

This setup is now:

  • ✔ Production-grade
  • ✔ Scalable
  • ✔ Upgrade-safe
  • ✔ ISP-style architecture
  • ✔ Fully tested with radclient

If you’re building prepaid vouchers, Wi-Fi cards, or temporary access accounts in FreeRADIUS — this is (one of) the correct way to do it.


Audit Summary (By Syed Jahanzaib)

SOP – Prepaid Time-Based Authentication (Audit-Friendly Flow)

This section documents the operational and control flow of the prepaid authentication system implemented using FreeRADIUS.
It is written for audits, compliance reviews, and operational SOPs.


SOP 1 – Prepaid Card Lifecycle

Objective:
Describe how prepaid cards are created, used, and retired.

Process Flow:

  • IT Administrator generates prepaid cards
  • Each card is stored in the FreeRADIUS database (radcheck)
  • Card record includes:
    • Username
    • Password
    • Prepaid time limit (in seconds)
    • Hard expiration date (safety control)
  • Card remains unused until first successful login
  • Time consumption starts only after authentication
  • Card becomes unusable when:
    • Prepaid time is exhausted OR
    • Expiration date is reached

Audit Controls:

  • Centralized credential storage
  • No manual intervention during usage
  • Automatic enforcement

SOP 2 – Authentication & Authorization Flow

Objective:
Explain how a login request is processed.

Process Flow:

  • User attempts login via NAS / captive portal
  • Access-Request is sent to FreeRADIUS
  • FreeRADIUS performs:
    • Username validation
    • Password verification
  • Expiration date is evaluated
  • Prepaid time quota is evaluated using accounting data
  • One of the following outcomes occurs:
    • Access-Accept (quota available)
    • Access-Reject (quota exhausted or expired)

Audit Controls:

  • Deterministic decision path
  • No ambiguity in enforcement
  • Fully automated

SOP 3 – Prepaid Time Enforcement Logic

Objective:
Describe how time usage is calculated and enforced.

Process Flow:

  • Allocated time is stored per user in radcheck
  • Actual usage is accumulated in radacct
  • Each login triggers:
    • Retrieval of total used session time
    • Comparison with allocated prepaid time
  • Enforcement is performed by the sqlcounter module
  • Authentication is rejected immediately when usage reaches or exceeds allocation

Audit Controls:

  • Time cannot exceed allocation
  • Enforcement occurs before session establishment
  • No reliance on client-side timers

SOP 4 – Accounting & Usage Tracking

Objective:
Demonstrate how usage is logged and auditable.

Process Flow:

  • Each user session generates accounting records
  • Accounting data includes:
    • Session start time
    • Session stop time
    • Total session duration
  • Usage accumulates across multiple sessions
  • Historical usage remains available for reporting and audits

Audit Controls:

  • Complete usage history
  • Non-repudiation
  • Supports forensic analysis

SOP 5 – Rejection Reason Handling (User Messaging)

Objective:
Ensure consistent and non-revealing rejection messages.

Process Flow (Priority Order):

  • If prepaid time quota is exhausted:
    • User receives message: “Time quota exhausted”
  • If hard expiration date is reached:
    • User receives message: “Account expired”
  • For all other failures:
    • User receives message: “Invalid username or password”

Audit Controls:

(zaib: although i would prefer not to categorize and clearly show each error, but still let us pass through)

  • No sensitive information leakage
  • Consistent messaging
  • Clear categorization of failure reasons

SOP 6 – Exception & Error Handling

Objective:
Describe system behavior in abnormal scenarios.

Handled Scenarios:

  • Incorrect credentials:
    • Authentication rejected
    • Event logged
  • Fully used prepaid card:
    • Authentication rejected
    • Time quota message returned
  • Expired card:
    • Authentication rejected
    • Expiration message returned
  • Database unavailable:
    • Authentication fails safely
    • No partial access granted

Audit Controls:

  • Fail-secure design
  • No bypass conditions
  • Logged outcomes

SOP 7 – Roles & Responsibilities (RACI Summary)

Role Responsibility
IT Administrator Card creation and policy configuration
FreeRADIUS Server Authentication, quota enforcement, accounting
NAS / Controller Session connectivity
End User Consumption of prepaid access
Auditor Review of logs, controls, and compliance

SOP 8 – Audit & Compliance Summary

Control Summary:

  • Prepaid access is enforced automatically
  • Time consumption is accurately tracked
  • Authentication is denied once limits are reached
  • All decisions are logged centrally
  • No manual override exists at user level

Audit Statement:

The prepaid authentication system enforces access using centrally managed credentials and accounting-based quota validation. Time usage is cumulative, automatically enforced, and fully auditable without manual intervention.


Final Note for Auditors

This design ensures:

  • Predictable enforcement
  • Strong access control
  • Minimal operational risk
  • Clear audit trail
  • Compliance with standard IT control frameworks