Setting Up FreeRADIUS with Google Workspace Authentication, VLAN Support per Client, and SSL Authentication
Table of Contents
FreeRADIUS is a powerful open-source RADIUS server that provides centralized authentication and authorization for users connecting to a network service. In this post, we’ll walk through setting up FreeRADIUS with Google Workspace authentication, per-client VLAN assignment, and SSL authentication. The result: multiple offices authenticate securely against Google, and users land in the right VLAN based on who they are and where they connect from.
The setup also handles a Google Workspace environment with multiple domains attached, working correctly with Google’s Secure LDAP functionality.
As a running example, we assume a Google Workspace environment with two domains, example.com and techwolf12.nl, and two offices, one in amsterdam and one in utrecht.
Installation #
We assume a Debian 13 server; other distros may require different steps or file paths, which you can adjust accordingly.
Install FreeRADIUS and its utilities:
sudo apt update
sudo apt install freeradius freeradius-utils freeradius-ldap
Certificates #
This setup uses certificates in three places, and it’s worth understanding each role before touching the config: the EAP server certificate that Wi-Fi clients validate, the CA bundle the server trusts, and the Google Secure LDAP client certificate used to talk to Google. Certificate problems are by far the most common reason an otherwise-correct FreeRADIUS setup fails, usually with vague supplicant-side errors.
The EAP server certificate #
During the EAP handshake, the RADIUS server presents a TLS certificate to the connecting device, just like a website would. For clients to accept it, the certificate needs more than just being signed by a trusted CA:
- Extended Key Usage must include TLS Web Server Authentication (
serverAuth, OID1.3.6.1.5.5.7.3.1). Windows, Android, and Apple supplicants all reject certificates without it. This is the classic “it’s signed by our CA but devices still won’t connect” gotcha, a CA signature alone is not enough if the EKU is missing. - The hostname must be in the Subject Alternative Name, e.g.
radius.infra.example.com. Modern supplicants (Android 11+ in particular, which no longer allows “don’t validate”) match the configured domain against the SAN, not the CN. - Key Usage should include
digitalSignatureandkeyEncipherment. - A reasonable validity period. Apple platforms are strict about long-lived TLS certificates; keep it at a year or two and automate renewal, because when this certificate expires, Wi-Fi breaks for everyone at once. Put the renewal date in your calendar or monitoring.
- The full chain in
certificate_file. If your CA uses an intermediate, concatenate the server certificate and the intermediate into one PEM file. Devices that don’t have the intermediate cached will otherwise fail chain validation.
If you generate the certificate with OpenSSL yourself, the FreeRADIUS package ships an xpextensions file in /etc/freeradius/3.0/certs/ with the correct extensions ready to use in your signing config.
Which CA to use #
A certificate from an internal/private CA is actually preferable to a public one here. Wi-Fi supplicants trust the CA you configure for that network profile, if you use a public CA, any certificate that CA has ever issued for anyone could pass validation on devices that skip hostname matching. With a private CA used only for your RADIUS infrastructure, the trust scope is exactly as narrow as it should be. The trade-off is that you must distribute the CA certificate to devices, which in a Google Workspace shop is easy: push it together with the Wi-Fi profile via Google MDM / endpoint management, so devices get the SSID, the CA, and the expected server name in one go.
The ca_file in the EAP configuration serves the opposite direction: it lists the CA(s) whose client certificates you accept for EAP-TLS. If you only use TTLS/PEAP with passwords, it’s unused, but configuring it correctly from day one means EAP-TLS (more later) is a small upgrade.
The Google Secure LDAP client certificate #
Google’s Secure LDAP service authenticates your server with a client certificate that you download from the Admin console. Two things to know: it’s valid for a long time but not forever, so track its expiry alongside your EAP certificate, and it identifies your whole LDAP client, treat the key with the same care as a service-account key. It goes in /etc/freeradius/3.0/certs/ as ldap-client.crt and ldap-client.key, readable only by the freerad user.
Google Workspace setup #
Create a new Secure LDAP client #
- In the Google Admin console, go to Apps → LDAP and click Add Client.
- Give it a name (e.g. “FreeRADIUS”) and set the access permissions: verify user credentials and read user information for the organizational units you want to authenticate. If you want group-based overrides (see later section), also allow reading group information.
- Download the generated client certificate and key, and place them on the server as
/etc/freeradius/3.0/certs/ldap-client.crtand/etc/freeradius/3.0/certs/ldap-client.key. - Generate access credentials (username/password) for the client, we’ll use these as
identityandpasswordin the LDAP module. - Turn the LDAP client ON for the organizational units that need Wi-Fi access.
Note that Secure LDAP requires a Google Workspace edition that includes it (Business Plus, Enterprise, or Cloud Identity Premium).
Configuration Files #
A working setup consists of several configuration files located in /etc/freeradius/3.0. Below we configure each file in turn; you can replace the entire file with these contents and adjust the values (IPs, secrets, domains, VLAN IDs) for your environment.
1. dictionary #
To make some parts easier, we define custom RADIUS attributes we can use internally:
ATTRIBUTE Ldap-DN 3003 string
ATTRIBUTE User-Company 3004 string
2. clients.conf #
Define the clients (access points, switches, NASes) that may talk to the RADIUS server. Each client is identified by its source IP address and a shared secret. It’s recommended to use a unique secret per location.
client localhost {
ipaddr = 127.0.0.1
proto = *
secret = testing123
require_message_authenticator = no
nas_type = other
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
client amsterdam-office {
ipaddr = 1.1.1.1 # Replace with your office IP or range
secret = secret-shared-with-accesspoints
}
client utrecht-office {
ipaddr = 1.0.0.1 # Replace with your office IP or range
secret = secret-shared-with-accesspoints
}
This works fine as long as every office has a static IP. If one of your locations sits behind a consumer connection with a changing IP, see the dynamic clients section further down.
3. proxy.conf #
Configure the proxy settings. It’s handy to list the different domains that will authenticate to your RADIUS server here as empty realms, so usernames like [email protected] are recognized without being proxied anywhere:
proxy server {
}
home_server localhost {
type = auth
ipaddr = 127.0.0.1
port = 1812
secret = testing123
response_window = 20
zombie_period = 40
revive_interval = 120
status_check = status-server
check_interval = 30
check_timeout = 4
num_answers_to_alive = 3
max_outstanding = 65536
coa {
irt = 2
mrt = 16
mrc = 5
mrd = 30
}
limit {
max_connections = 16
max_requests = 0
lifetime = 0
idle_timeout = 0
}
}
realm LOCAL {
}
realm example.com {
}
realm techwolf12.nl {
}
4. sites-enabled/default #
The default virtual server does the heavy lifting: it maps the user’s domain to the right LDAP base DN, authenticates against Google, and assigns a VLAN based on the combination of office (via Client-Shortname) and company:
server default {
listen {
type = auth
ipaddr = *
port = 0
limit {
max_connections = 0
lifetime = 0
idle_timeout = 30
}
}
listen {
ipaddr = *
port = 0
type = acct
limit {
}
}
listen {
type = auth
ipv6addr = ::
port = 0
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
listen {
ipv6addr = ::
port = 0
type = acct
limit {
}
}
authorize {
filter_username
preprocess
chap
mschap
digest
suffix
eap {
ok = return
}
files
-sql
if (User-Name =~ /@example\.com$/i) {
update request {
Ldap-DN := "dc=example,dc=com"
User-Company := "example"
}
} elsif (User-Name =~ /@techwolf12\.nl$/i) {
update request {
Ldap-DN := "dc=techwolf12,dc=nl"
User-Company := "techwolf12"
}
}
# Add more company-specific rules here...
# Authenticate the user with the LDAP module
-ldap
# Pick the VLAN based on the office you are in
if (Client-Shortname == "amsterdam") {
if (User-Company == "techwolf12") {
update reply {
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = 20
}
} else {
update reply {
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = 40
}
}
}
if (Client-Shortname == "utrecht") {
if (User-Company == "techwolf12") {
update reply {
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = 70
}
} else {
update reply {
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = 81
}
}
}
# Group-based overrides go here -- see the
# "Per-group overrides with LDAP groups" section below.
expiration
logintime
pap
if (User-Password) {
update control {
Auth-Type := ldap
}
}
Autz-Type New-TLS-Connection {
ok
}
}
authenticate {
Auth-Type PAP {
ldap
}
Auth-Type CHAP {
chap
}
Auth-Type MS-CHAP {
mschap
}
mschap
digest
Auth-Type LDAP {
ldap
}
eap
}
preacct {
preprocess
acct_unique
suffix
files
}
accounting {
detail
unix
-sql
exec
attr_filter.accounting_response
}
session {
}
post-auth {
if (session-state:User-Name && reply:User-Name && request:User-Name && (reply:User-Name == request:User-Name)) {
update reply {
&User-Name !* ANY
}
}
update {
&reply: += &session-state:
}
-sql
exec
remove_reply_message_if_eap
Post-Auth-Type REJECT {
-sql
attr_filter.access_reject
eap
remove_reply_message_if_eap
}
Post-Auth-Type Challenge {
}
Post-Auth-Type Client-Lost {
}
if (EAP-Key-Name && &reply:EAP-Session-Id) {
update reply {
&EAP-Key-Name := &reply:EAP-Session-Id
}
}
}
pre-proxy {
}
post-proxy {
eap
}
}
5. sites-enabled/inner-tunnel #
The inner-tunnel virtual server handles the protected inner part of tunneled EAP methods (PEAP). It copies the VLAN attributes back to the outer session so they end up in the final Access-Accept:
server inner-tunnel {
listen {
ipaddr = 127.0.0.1
port = 18120
type = auth
}
authorize {
filter_username
chap
mschap
suffix
update control {
&Proxy-To-Realm := LOCAL
}
eap {
ok = return
}
files
-sql
-ldap
expiration
logintime
pap
}
authenticate {
Auth-Type PAP {
pap
}
Auth-Type CHAP {
chap
}
Auth-Type MS-CHAP {
mschap
}
mschap
eap
}
session {
radutmp
}
post-auth {
-sql
Post-Auth-Type REJECT {
-sql
attr_filter.access_reject
update outer.session-state {
&Module-Failure-Message := &request:Module-Failure-Message
}
}
update {
&outer.session-state:Tunnel-Type := Tunnel-Type[*]
&outer.session-state:Tunnel-Medium-Type := Tunnel-Medium-Type[*]
&outer.session-state:Tunnel-Private-Group-Id := Tunnel-Private-Group-Id[*]
&outer.session-state:User-Name := User-Name[*]
}
}
pre-proxy {
}
post-proxy {
eap
}
}
6. mods-enabled/eap #
Configure the EAP module for SSL authentication. EAP-TTLS with GTC is the default here because Google’s Secure LDAP only gives us a password-verification interface (no NT hashes), which rules out MSCHAPv2 against LDAP. MSCHAPv2 is also considered to be insecure, so we don’t want to use it. This does mean, that on Windows devices, username/password login does not work. The certificate files referenced here are the ones from the Certificates section:
eap {
default_eap_type = ttls
timer_expire = 60
ignore_unknown_eap_types = no
cisco_accounting_username_bug = no
max_sessions = ${max_requests}
gtc {
auth_type = PAP
}
tls-config tls-common {
private_key_file = /etc/freeradius/3.0/certs/radius.infra.example.com.key
certificate_file = /etc/freeradius/3.0/certs/radius.infra.example.com.pem
ca_file = /etc/freeradius/3.0/certs/authorized-ca.pem
cipher_list = "DEFAULT"
cipher_server_preference = no
tls_min_version = "1.2"
tls_max_version = "1.2"
ecdh_curve = ""
}
tls {
tls = tls-common
}
ttls {
tls = tls-common
default_eap_type = gtc
copy_request_to_tunnel = no
use_tunneled_reply = yes
virtual_server = "default"
}
peap {
tls = tls-common
default_eap_type = mschapv2
copy_request_to_tunnel = no
use_tunneled_reply = yes
virtual_server = "inner-tunnel"
}
mschapv2 {
}
}
7. mods-enabled/ldap #
Configure the LDAP module for Google authentication. Note the base_dn = &Ldap-DN in the user section, that’s where the per-domain DN we set in the authorize section comes into play. The group section is what powers the group-based overrides in the next section; cacheable_name = yes makes group memberships get resolved once per request instead of once per comparison:
ldap {
server = 'ldaps://ldap.google.com'
port = 636
base_dn = 'dc=example,dc=com'
identity = 'x'
password = x
update {
control:Password-With-Header += 'password'
control: += 'radiusControlAttribute'
request: += 'radiusRequestAttribute'
reply: += 'radiusReplyAttribute'
}
user_dn = "LDAP-UserDn"
user {
base_dn = &Ldap-DN
filter = "(uid=%{%{Stripped-User-Name}:-%{User-Name}})"
}
group {
base_dn = "${..base_dn}" # Depending on your network groups, you might want to use &Ldap-DN instead for the user's authenticated DN, or keep it to the default DN
filter = '(objectClass=posixGroup)'
membership_attribute = 'memberOf'
name_attribute = cn
cacheable_name = yes
}
client {
base_dn = "${..base_dn}"
filter = '(objectClass=radiusClient)'
attribute {
ipaddr = 'radiusClientIdentifier'
secret = 'radiusClientSecret'
}
}
options {
chase_referrals = yes
rebind = yes
res_timeout = 10
srv_timelimit = 3
net_timeout = 1
idle = 60
probes = 3
interval = 3
ldap_debug = 0x0028
}
tls {
start_tls = no
certificate_file = /etc/freeradius/3.0/certs/ldap-client.crt
private_key_file = /etc/freeradius/3.0/certs/ldap-client.key
require_cert = 'allow'
}
pool {
start = ${thread[pool].start_servers}
min = ${thread[pool].min_spare_servers}
max = ${thread[pool].max_servers}
spare = ${thread[pool].max_spare_servers}
uses = 0
retry_delay = 30
lifetime = 0
idle_timeout = 60
}
}
Per-group overrides with LDAP groups #
The domain-and-office logic above covers the general case, but real networks always have exceptions: the infra team that needs the management VLAN from any office, contractors that should be fenced off regardless of their domain, or an emergency “kill switch” group to lock someone out of Wi-Fi without suspending their whole Google account.
Google Groups are exposed through Secure LDAP, so we can use plain Google Groups for all of this, adding someone to a group in the Admin console changes their network access on the next (re)authentication, with zero RADIUS config changes.
With the group section from the LDAP module above in place, the LDAP-Group attribute becomes available in unlang. Comparing against it triggers a membership lookup for the current user. Add the override rules to the authorize section of sites-enabled/default, after the office VLAN logic, the := operator overwrites whatever was set before, so the last match wins, which is exactly what you want for exceptions:
# --- Group-based overrides (place after the office VLAN logic) ---
# Blocked group: kicked off Wi-Fi entirely, regardless of anything else.
if (LDAP-Group == "wifi-blocked") {
update reply {
Reply-Message := "Access denied by policy"
}
reject
}
# Infra team: always the management VLAN, from any office.
if (LDAP-Group == "wifi-infra") {
update reply {
Tunnel-Type := VLAN,
Tunnel-Medium-Type := IEEE-802,
Tunnel-Private-Group-Id := 99
}
}
# Contractors: isolated VLAN and forced reauthentication every 8 hours.
if (LDAP-Group == "wifi-contractors") {
update reply {
Tunnel-Type := VLAN,
Tunnel-Medium-Type := IEEE-802,
Tunnel-Private-Group-Id := 150,
Session-Timeout := 28800
}
}
The group names here are the cn of the group as exposed via LDAP, for a Google Group [email protected], that’s wifi-infra. If you run multiple domains in one Workspace, keep group names unique across domains, or match on the full DN instead of the name to avoid ambiguity.
A couple of practical notes. Make sure your Secure LDAP client has permission to read group information for the relevant organizational units, or every comparison silently fails (run freeradius -X and look for the group search to verify). Each group comparison can cost an LDAP round trip to Google, so keep the number of distinct groups you check small, with cacheable_name = yes, all the user’s groups are fetched once per request and subsequent comparisons are free. And remember the override only applies at (re)authentication time; combine it with a CoA/Disconnect from your controller if “immediately” matters for the blocked group.
Dynamic clients: NASes with changing IP addresses #
The clients.conf setup above assumes every office has a static IP. But what about an access point behind a consumer connection whose IP changes every so often? RADIUS identifies clients by source IP, so a plain static client block won’t work.
FreeRADIUS 3.x has a feature for exactly this: dynamic clients. You define a network range instead of a fixed IP, and point it at a virtual server that decides, per unknown source IP, whether it’s a legitimate client and what its secret is. We’ll combine that with dynamic DNS: each roaming NAS keeps a DNS record up to date (most routers can do this out of the box), and FreeRADIUS verifies incoming packets by resolving that hostname, directly at the authoritative nameserver, so resolver caches can never serve us a stale IP.
1. clients.conf addition #
client dynamic_dns_clients {
# Narrow this as much as you can (e.g. your ISP's ranges).
ipaddr = 0.0.0.0/0
# Name of the virtual server that resolves/validates the client.
dynamic_clients = dynamic_clients
# How long a discovered client stays cached. While the entry is alive,
# DNS is NOT re-checked for that IP. When the NAS gets a new IP, the new
# address is unknown and gets validated immediately; the stale entry
# simply ages out.
lifetime = 300
}
2. The map file: /etc/freeradius/dyn-clients.map #
One line per dynamic NAS, with a per-device secret. Adding a NAS later is just adding a line, no restart needed:
# hostname shortname secret auth_ns (optional, "-" = auto)
nas1.dyn.example.com nas1 S3cret-One ns1.example.com
nas2.dyn.example.com nas2 S3cret-Two ns1.example.com
ap-loft.example.net ap-loft An0ther-Secret -
The shortname column is what shows up as Client-Shortname, so you can use it in the VLAN logic in the default virtual server exactly like the static offices. This file contains secrets, so protect it:
sudo chown freerad:freerad /etc/freeradius/dyn-clients.map
sudo chmod 600 /etc/freeradius/dyn-clients.map
3. The lookup script: /usr/local/bin/radius-dyn-client.sh #
The script walks the map file, resolves each hostname straight at its authoritative nameserver with dig +norecurse, and on a match prints the client attributes for FreeRADIUS to pick up:
#!/bin/sh
# radius-dyn-client.sh <source-ip>
#
# Looks up <source-ip> against a map file of dynamic-DNS hostnames.
# Each hostname is resolved *directly at its authoritative nameserver*
# (dig +norecurse @ns), so no resolver cache is involved.
MAP="/etc/freeradius/dyn-clients.map"
SRC_IP="$1"
[ -n "$SRC_IP" ] || exit 0
[ -r "$MAP" ] || exit 0
while read -r host shortname secret ns _rest; do
case "$host" in ""|\#*) continue ;; esac
[ -n "$secret" ] || continue
# Column 4 (authoritative NS) is optional. Filling it in saves a
# lookup per host per validation.
if [ -z "$ns" ] || [ "$ns" = "-" ]; then
zone=${host#*.}
ns=$(dig +short NS "$zone" | head -n1)
fi
[ -n "$ns" ] || continue
# +norecurse against the authoritative server: the answer comes
# straight from the zone data, never from a cache.
resolved=$(dig +short +norecurse +time=2 +tries=1 A "$host" @"$ns" | head -n1)
if [ -n "$resolved" ] && [ "$resolved" = "$SRC_IP" ]; then
printf 'FreeRADIUS-Client-Shortname = "%s", FreeRADIUS-Client-Secret = "%s"\n' \
"$shortname" "$secret"
exit 0
fi
done < "$MAP"
exit 0
Install it with sudo install -m 755 radius-dyn-client.sh /usr/local/bin/.
4. mods-enabled/dynclient_lookup #
An exec module instance runs the script and parses its stdout directly into control attributes, no string matching in unlang needed:
exec dynclient_lookup {
wait = yes
program = "/usr/local/bin/radius-dyn-client.sh %{Packet-Src-IP-Address}"
input_pairs = request
output_pairs = control
shell_escape = yes
# Worst case is roughly 2s per mapped host that fails to resolve.
# Size this to: 2 * number_of_hosts, plus margin.
timeout = 15
}
5. sites-enabled/dynamic-clients #
Finally, the virtual server that the dynamic_clients directive in clients.conf points at. It runs once per unknown source IP (and again after lifetime expires):
server dynamic_clients {
authorize {
dynclient_lookup
# The script only emits a secret when the source IP matched a
# hostname's fresh A record.
if (&control:FreeRADIUS-Client-Secret) {
update control {
&FreeRADIUS-Client-IP-Address := "%{Packet-Src-IP-Address}"
}
ok
}
else {
reject
}
}
}
Enable the module and site, then restart:
cd /etc/freeradius/3.0
sudo ln -s ../mods-available/dynclient_lookup mods-enabled/
sudo ln -s ../sites-available/dynamic-clients sites-enabled/
sudo systemctl restart freeradius
How it behaves, and what to watch out for #
The DNS lookup only happens for unknown source IPs. Once validated, the client is cached for lifetime seconds and packets flow with zero DNS overhead. When a NAS gets a new IP, the first packet from the new address triggers an immediate fresh validation, so IP changes are picked up right away; lifetime only bounds how long an old, revoked IP would keep working.
Two caveats worth knowing. First, the lookup script runs synchronously in a worker thread, so keep the per-host dig timeouts tight (the +time=2 +tries=1 above) and the map file short, for more than a dozen or so devices, a database keyed by IP would be a better fit than linear probing. Second, this design trusts DNS: if your zone isn’t DNSSEC-signed and the path to the authoritative nameserver can be spoofed, an attacker who forges the answer only needs the shared secret. For anything fully internet-facing, RadSec (RADIUS over TLS, where clients authenticate with certificates instead of source IPs) is the more robust long-term answer.
Testing #
Run FreeRADIUS in debug mode to watch everything happen:
sudo systemctl stop freeradius
sudo freeradius -X
Then authenticate from a device, or test locally with radtest. Things to check in the debug output: the LDAP bind and user search succeeding, the group search firing when a group comparison runs, the correct Tunnel-Private-Group-Id in the Access-Accept, and, for dynamic clients, the server dynamic_clients section firing on the first packet from an unknown IP, followed by normal processing in the default virtual server.
Next steps #
With this setup you get a single FreeRADIUS instance that authenticates users from multiple Google Workspace domains via Secure LDAP, drops them into the right VLAN based on office and company, supports per-group overrides driven entirely by Google Groups, handles SSL-based EAP methods with properly validated certificates, and even accepts access points on dynamic IP addresses, verified against fresh, authoritative DNS on every IP change.
A natural next step from here is EAP-TLS with per-device client certificates: the tls-common block and ca_file are already in place, so it’s mostly a matter of standing up a small device CA and pushing certificates through your MDM.