You install a profile, the tunnel comes up, and everything looks finished. Then the phone reboots, on-demand wakes the connection with no app in the foreground, and the tunnel refuses to start. The profile is still there and the password is still correct, which is why this failure sends people looking in the wrong place. A Network Extension cannot read your secret from the profile: it needs a keychain reference it can resolve on its own.

The walkthrough below follows one connection attempt through that handoff, from the query that builds the reference to the reboot that tests it.

The profile stores a handle, not the secret

NEVPNProtocol exposes passwordReference and sharedSecretReference for a reason. When the daemon starts a tunnel it may not be running inside your process, so it cannot call your code and ask for a password. It reads a persistent keychain reference, resolves it in its own context, and fetches the item itself.

You build that reference by asking the keychain for it, which is a different request than asking for the data:

let query: [String: Any] = [
  kSecClass as String: kSecClassGenericPassword,
  kSecAttrService as String: "vpn-credentials",
  kSecAttrAccount as String: "user",
  kSecReturnPersistentRef as String: true,
  kSecMatchLimit as String: kSecMatchLimitOne
]

Hand the returned object to the protocol, not the item data. In practical terms the profile records where the secret lives, which is also why a configuration backup does not quietly duplicate the password into a plist.

Why the API answers -50

errSecParam means the request or the value was malformed for the operation, and it is the error most often seen when the reference and the data requests get mixed up. Asking for data while the receiving code expects a reference, assigning raw bytes to passwordReference, or querying with an access group the app cannot use all produce it.

Wrapper libraries invite this. A helper that returns bytes is not interchangeable with one that returns a reference, so the repair is usually a different query rather than a different password.

The same mismatch explains a profile that saves without complaint and then fails to connect: what got stored is a blob, not a handle, and the daemon has nothing to resolve.

Saves fine, fails at connection time

Saving exercises your app's context. Connecting exercises the daemon's, and the two do not have the same access. A profile can therefore pass every test you run in the simulator and fail on the first on-demand start.

That is also the shape of the reboot symptom: it works while the app is open and dies when the system starts the tunnel by itself.

Test the background path deliberately before shipping. Trigger an on-demand connection with the app closed, then read the device console rather than the app log, because the failure is recorded by the process that tried to resolve the reference. If the console shows the daemon rejecting the item, the reference is fine and the item's accessibility is the problem.

Accessibility decides whether a reboot works

The item has to be readable by whichever system process launches the tunnel, and that is a wider audience than your app while it sits in the foreground. That constraint picks the accessibility attribute for you. An item protected until first unlock is available to a background start after the device has been unlocked once; an item that requires the device to be unlocked right now is not.

Choosing the stricter class is tempting and it is exactly what breaks an on-demand profile on a locked phone.

After changing the class, recreate the item rather than editing attributes on the existing one, then re-save the protocol with a fresh reference. Attribute changes on an existing item do not always produce the behaviour the documentation implies, and a rebuilt item removes the doubt.

Rotation is where saved profiles rot

Changing the password in the keychain does not automatically change what the saved profile points at. Deleting the item is worse: the profile keeps a reference to something that no longer exists, and the failure looks like an authentication rejection.

Rotate in one order. Update the item, query a new persistent reference, re-save the protocol, then connect with the app closed to prove the daemon can still resolve it. Keep the user password and the shared secret in separate items so each rotates on its own schedule, and never log the query dictionary or the reference itself. The handle is not a secret, but it is a map to one.

Allakkat attuumassuteqartut