Netfox’s Wi-Fi tool puts a padlock beside every network: green and closed when the traffic is encrypted, red and open when it isn’t. Version 0.29.0 added a third case, Enhanced Open (OWE): a network you join without a password whose traffic is still encrypted. It deserves the green lock, and the join sheet tells you so.
A day after shipping it, I filtered the list to “No password” and saw two networks with a green closed padlock. One, by the look of its name, was a neighbour’s smart vacuum showing its setup hotspot. Its radio didn’t even do 802.11n, and it was supposedly speaking a protocol from 2018. system_profiler SPAirPortDataType agreed with my suspicion: Security: None. Netfox said Enhanced Open, Open.
The code looked right
This is the classifier as it shipped:
private static let enhancedOpenModes: [CWSecurity] = [.OWE, .oweTransition]
static func classifyAccess(for cwNetwork: CWNetwork) -> WiFiNetwork.Access {
WiFiNetwork.Access.classify(
open: cwNetwork.supportsSecurity(.none),
enhancedOpen: enhancedOpenModes.contains { cwNetwork.supportsSecurity($0) },
password: personalModes.contains { cwNetwork.supportsSecurity($0) },
enterprise: enterpriseModes.contains { cwNetwork.supportsSecurity($0) }
)
}OWE has a transition mode: the access point runs an open network plus a hidden OWE one, and a client that speaks OWE quietly uses the encrypted side. So .oweTransition looked like exactly the flag to check. The header documents it as “OWE Transition” and nothing more.
Asking CoreWLAN directly, with no access point and no permission
To see what the API really answers, I needed networks whose security I knew. I didn’t have an OWE access point, and on recent macOS an app without Location access gets scan results with the SSID, the BSSID and the raw information elements blanked out.
But CWNetwork keeps everything it parsed in a private dictionary, and supportsSecurity(_:) reads from it. Dumping that dictionary from a process with no Location permission showed that the parsed security elements survive the redaction:
let networks = try CWWiFiClient.shared().interface()!.scanForNetworks(withSSID: nil)
for network in networks where network.supportsSecurity(.wpa2Personal) {
let record = (network as NSObject).value(forKey: "scanRecord") as? [String: Any] ?? [:]
print(network.ssid ?? "<redacted>", record["RSN_IE"] ?? "no RSN")
}<redacted> {
"IE_KEY_RSN_AUTHSELS" = ( 2 );
"IE_KEY_RSN_MCIPHER" = 4;
"IE_KEY_RSN_UCIPHERS" = ( 4 );
"IE_KEY_RSN_VERSION" = 1;
...
}AUTHSELS is the list of AKM suites: 2 is a WPA2 passphrase (PSK), 8 is WPA3’s SAE, 18 is OWE. That’s enough to write the record myself and hand it to a fresh CWNetwork:
func network(_ record: [String: Any]) -> CWNetwork {
let network = CWNetwork()
network.setValue(record as NSDictionary, forKey: "scanRecord") // private: probes and tests only
return network
}
func rsn(_ akms: [Int], mfpRequired: Bool = false) -> [String: Any] {
["IE_KEY_RSN_VERSION": 1, "IE_KEY_RSN_MCIPHER": 4, "IE_KEY_RSN_UCIPHERS": [4],
"IE_KEY_RSN_AUTHSELS": akms,
"IE_KEY_RSN_CAPS": ["MFP_CAPABLE": 1, "MFP_REQUIRED": mfpRequired ? 1 : 0]]
}
let open = network(["CAPABILITIES": 0x421]) // no RSN, privacy bit off
let owe = network(["CAPABILITIES": 0x431, "RSN_IE": rsn([18], mfpRequired: true)])
let wpa2 = network(["CAPABILITIES": 0x431, "RSN_IE": rsn([2])])Then ask every flag. This is what macOS 27 answered:
An open network says yes to .oweTransition. So does a record with nothing in it at all. A WPA2-only network says yes to WPA3 Transition and to WPA Mixed.
It’s not a bug, it’s a different question
Once the table is in front of you, the pattern is clear. supportsSecurity(_:) doesn’t answer “does this access point advertise this mode?”. It answers “could a profile saved under this mode join this access point?”. A WPA3 Transition profile can join a WPA2 network or a WPA3 one. A WPA/WPA2 Mixed profile can join a WPA2 network. An OWE Transition profile can join a plain open network, because that’s what the open half of a transition pair is.
The base flags I checked (.none, .wpaPersonal, .wpa2Personal, .wpa3Personal, .OWE) track what the network advertises, because for them the two questions have the same answer. The transition and mixed flags are where they diverge.
It had been costing Netfox in a second place I hadn’t noticed: the security label listed every flag that said yes. A plain WPA2 network read “WPA Mixed, WPA2 Personal, WPA3 Transition”, and the list, which shows the first mode, said WPA. On my own scan that was 17 networks out of 24.
And a real OWE transition network still answers .OWE itself: an open record carrying the scan key SCAN_RESULT_OWE_MULTI_SSID = 1 (the open half of a transition pair, judging by its name) comes back yes to .OWE too. So .oweTransition wasn’t needed for anything.
The fix is small; the lesson is in the tests
// 0.29.0
enhancedOpen: [.OWE, .oweTransition].contains { cwNetwork.supportsSecurity($0) },
// 0.29.1
enhancedOpen: cwNetwork.supportsSecurity(.OWE),The label now names only base modes, so a transition network reads “WPA2 Personal, WPA3 Personal”, which says the same thing without claiming anything the network doesn’t do.
The more interesting question is why the tests passed. The code that matches a scanned network against saved profiles was tested with a fake:
private func advertising(_ modes: [CWSecurity]) -> (CWSecurity) -> Bool {
{ modes.contains($0) }
}
#expect(profileMatches(.wpa2Personal, supports: advertising([.wpa3Transition])))That test describes an access point that advertises WPA3 Transition and nothing else. CoreWLAN never reports one. The fake encoded my assumption about the API, so it could only ever agree with my code.
The tests now build real CWNetworks from scan records, with the same helper as above, and one test pins the premise itself:
@Test func coreWLANAnswersTheTransitionFlagsForNetworksThatDoNotCarryThem() {
#expect(ScanRecords.open().supportsSecurity(.oweTransition))
#expect(ScanRecords.wpa2Personal().supportsSecurity(.wpa3Transition))
#expect(ScanRecords.wpa2Personal().supportsSecurity(.wpaPersonalMixed))
}If Apple ever changes what the flag means, that test fails first, and its doc comment says which comment in the classifier has become stale.
Caveats, honestly
scanRecordis private. Setting it through KVC is fine in a test target or a throwaway probe, and has no place in the app you ship.The keys and the table above were read on macOS 27. They’re a measurement, not a contract, which is the point of the premise test.
None of this needs a real OWE access point or Location access, and that is what makes it worth knowing: you can check what Wi-Fi security code does with any security mode, from a test, in seconds.
The fix shipped in Netfox 0.29.1: open networks show as open again, and the security label names only the modes a network uses.
Netfox is free, a universal binary (Apple Silicon + Intel), runs on macOS 15.6+, and is signed and notarized.
Website: netfox.app
Download: https://netfox.app/download




