Product Showcase

The padlock that lied: what CoreWLAN's supportsSecurity really answers

Every open Wi-Fi network came back as Enhanced Open. Why the API said yes, and how to test Wi-Fi security code with no Location permission and no real access point.

•
5 min read
Giovambattista Fazioli
Senior Full-stack Engineer, Lead Developer·
The padlock that lied: what CoreWLAN's supportsSecurity really answers

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:

Access point

.none

.OWE

.oweTransition

.wpa2Personal

.wpa3Personal

.wpa3Transition

.wpaPersonalMixed

Open, no RSN

yes

–

yes

–

–

–

–

OWE (AKM 18)

–

yes

yes

–

–

–

–

WPA2 (AKM 2)

–

–

–

yes

–

yes

yes

WPA2 + WPA3 (AKM 2, 8)

–

–

–

yes

yes

yes

yes

WPA3 (AKM 8)

–

–

–

–

yes

yes

–

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

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

Comments (1)

Join the discussion by logging into your account.

Igor Ganapolsky

The table is the whole bug. supportsSecurity(.oweTransition) answers whether an OWE Transition profile could join, not whether the AP advertised OWE. A record with no RSN IE, and an empty scan record, both say yes, so a no-password filter that ORs .OWE with .oweTransition paints Enhanced Open on a hotspot whose system_profiler line is Security: None. The base flags stay honest because for .none, .OWE, and .wpa2Personal the join question and the advertisement question are the same. The transition flags are where they split. That is also why a WPA2-only network (AKM 2) came back yes for .wpa3Transition and the label led with WPA. A fake that returns true only for the modes you hand it can never catch that. Building CWNetwork from a scan record with AUTHSELS 18 versus no RSN is the right premise test. An empty scan record saying yes to oweTransition is what makes that flag useless as a classifier. scanRecord through KVC belongs in the test target only.

Giovambattista Fazioli
Giovambattista Fazioli

Senior Full-stack Engineer, Lead Developer

Italian Senior Full-stack Engineer and Lead Developer on the Cloud team at Namecheap. I build developer tools, React/Mantine UI components and native macOS apps — mostly open source. I work across TypeScript, Next.js, Go and SwiftUI, and I've been coding since the Commodore/Assembly days. Creator of WP Bones and 25+ Mantine extensions, and maintainer of the Undolog open-source studio.

Subscribe to Giovambattista Fazioli's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.