Engineering

How LAPSlock was built

An iOS app that reads Windows LAPS passwords and BitLocker recovery keys from Entra ID and Intune, with no server in the credential path. The architecture, the authentication model, every Graph call, the on-device controls, and the dead ends.

This is written for people who run Entra ID and Intune. It assumes you know what Windows LAPS, delegated permissions and PIM are, and it is more interested in the decisions and the mistakes than in the feature list. Where I state what Microsoft Graph does, I have checked it against the reference documentation or against a live tenant, and I say which. Where I was unsure, I say that too.

1. The problem

You are standing at a Windows machine that will not log in. The local administrator password is in Entra, backed up by Windows LAPS. The admin center is open on a laptop somewhere else, behind a lock screen, possibly on another floor. Your phone is in your pocket, and your account already holds the role that can read the password. The gap between those two facts is the whole product.

LAPSlock is a native iOS app that, using the administrator's own delegated permissions, searches every managed device in the tenant, reveals the current LAPS password and the password history Graph returns with it, reveals BitLocker recovery keys per volume, requests just-in-time PIM activation when the role is eligible rather than active, and lets a managed service provider switch between customer tenants. Each reveal is a live Graph call under the user's own token, and it lands in the tenant's audit log exactly as a read from the portal would. The app grants nothing. Without a directory role that can read LAPS passwords, it cannot read them either.

The password history turned out to matter more than I expected. A device that stopped checking in weeks ago is still on an older password, and that is precisely the machine you are standing at.

One sentence on the business model, because the design depends on it: there is a free tier metered on the device, Apple subscriptions, and tenant-keyed organisation licences. Nothing in that model required a server to see a credential, and the architecture was shaped to keep it that way.

2. Architecture, and whether there is a backend

Modules as the security design

The app is one Xcode target plus a Swift package split into modules whose boundaries are the security design. The module that touches passwords, CredentialKit, imports Foundation and the authentication protocol and nothing else. Adding a logging framework, an analytics SDK, a crash reporter or the licensing layer to it is a compile error, enforced twice: by the package manifest's dependency lists, and by a shell script that greps the imports and fails the build on any forbidden module. That script runs before every commit. The licensing and subscription modules are forbidden from importing CredentialKit in the other direction, so a receipt and a local administrator password never share a link graph.

ModuleRoleAllowed imports
AuthKitAuth protocol and models, the tenant pin, consent helpers, Graph request-id tracerFoundation
AuthKitMSALThe only module that links MSAL; its implementation is entirely inside #if os(iOS)AuthKit, MSAL
CredentialKitLAPS and BitLocker providers, the SensitiveValue typeFoundation, AuthKit
InventoryKitDevice list, paging, search, background fill, favourites, optional user namesAuthKit, CredentialKit types
PrivilegedAccessKitPIM eligibility, policy, activation, claims-challenge parsingFoundation, AuthKit
LicensingKitEntitlement token verification, the reveal meter, keychain storesFoundation, CryptoKit, Security
SubscriptionKitStoreKit 2 catalogue and entitlement mergeFoundation, StoreKit, LicensingKit
DiagnosticsKitThe support report, structurally unable to carry a credentialFoundation
PlatformSecurityBiometric gate, reveal window, app-switcher cover, screenshot detection, clipboardFoundation, UIKit, LocalAuthentication

MSAL arrives as a checksum-pinned binary target, Microsoft's own signed XCFramework. It is the only third-party dependency in the app, and the credential path's link graph contains no third-party binary at all.

One reveal, start to finish

  1. The user taps Reveal on a device.
  2. The free-tier meter is checked. It is a ledger of reveal timestamps in the device Keychain. If it is exhausted the user is told now, before anything else happens.
  3. The biometric gate runs: LAContext with .deviceOwnerAuthentication, so Face ID, Touch ID or the passcode. A cancelled prompt stops here. No network call, no audit event in the tenant.
  4. MSAL acquires a token silently for the required scope against the pinned tenant authority. The token's tid is compared to the tenant the app deliberately selected.
  5. One Graph GET over an ephemeral URLSession: no cache, no cookie store.
  6. The response is parsed with JSONSerialization into a SensitiveValue, a final class that is not Codable, has no value-exposing description, and wipes itself on deinit.
  7. The meter is charged, now that the fetch has succeeded. Checking before the gate and charging after the response are two separate calls on purpose: nobody completes Face ID only to learn they are out of reveals, and a cancelled prompt costs nothing.
  8. The credential is published to the screen only once the scene is active, so the app-switcher cover can never race it. A sixty-second window starts. On timeout, hide, backgrounding or screen-capture detection, the value is wiped.

Is there a backend?

In the credential path, no. The app talks to exactly three hosts, and the third only in one circumstance.

HostWhenWhat crosses the wire
login.microsoftonline.comSign-in and refreshStandard Entra delegated authentication through MSAL
graph.microsoft.comBrowsing, reveals, PIMGraph requests carrying the user's delegated token
Kainor's entitlement endpoint (Azure Functions)Only after an organisation licence is activated, then roughly monthlyA version number and the tenant GUID. It returns a signed tier.

A free-tier install or an Apple-subscription install never contacts Kainor. The entitlement endpoint never receives a Microsoft token, a user, a device or a credential, and it cannot act in anyone's tenant because it holds nothing that could. Its request and response format, and what the server does and does not log, are published as a contract in the repository, and the network transparency document is a ten-minute proxy recipe for checking all of this yourself. iOS has no practical reproducible builds, so observed network behaviour is the verifiable claim, and the published source explains the binary rather than proving it.

3. Authentication

Delegated, not application permissions

This was the first decision and everything else follows from it.

How MSAL is configured

The scopes, and when each is requested

Consent is incremental. The app asks for the minimum at sign-in and adds scopes only when a feature is first used or explicitly enabled. The ReadBasic variants return LAPS and BitLocker metadata, rotation dates and which volumes have keys, without the secret. That is all the device list needs, so a user who only browses never consents to credential read.

WhenScopes
Sign-in (read-only baseline)DeviceManagementManagedDevices.Read.All
Device.Read.All
DeviceLocalCredential.ReadBasic.All
BitLockerKey.ReadBasic.All
First revealDeviceLocalCredential.Read.All
BitLockerKey.Read.All
Rotation toggle enabledDeviceManagementManagedDevices.ReadWrite.All
User display names toggle (off by default; the toggle names the permission it adds)User.ReadBasic.All
PIM toggle enabledRoleEligibilitySchedule.Read.Directory
RoleAssignmentSchedule.ReadWrite.Directory
RoleManagementPolicy.Read.Directory
PrivilegedEligibilitySchedule.Read.AzureADGroup
PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup
RoleManagementPolicy.Read.AzureADGroup

Admin consent in other tenants

There is no LAPSlock account system. It is ordinary Entra consent. A user whose tenant permits user consent sees the Microsoft prompt the first time each scope is needed. Where tenant policy requires an administrator, Entra answers with one of AADSTS65001, AADSTS90094 or AADSTS900971. The app recognises those, explains the state in plain words, and offers a shareable admin-consent link, the standard /adminconsent URL for that tenant, together with a short message the user can send to their administrator. Approving from the link grants the organisation; approving inside the app grants the user.

For MSPs the same thing applies per customer. Each customer tenant's administrator consents once, and the technician's access there is whatever their guest, GDAP or dedicated account can already do. Switching tenants changes the pinned authority, clears the device list, discards any credential on screen, and on relaunch the app returns to the home tenant on purpose. Operating inside a customer should be a decision made now, not inherited from last week.

4. Graph API usage

Everything is v1.0 and documented unless marked beta. The credential path parses with JSONSerialization rather than Codable, because a synthesized Codable conformance drags a description and a memberwise initialiser along with it, and a credential type must have neither.

Inventory

Windows LAPS

BitLocker

macOS LAPS

The only macOS-specific function is GET /beta/deviceManagement/managedDevices/{id}/retrieveDeviceLocalAdminAccountDetail. Its documented response type has exactly one property, passwordLastRotationDateTime. There is no password in the contract. In practice the function also returned HTTP 500 for every ADE-enrolled, LAPS-managed Mac I tested, on two dates four weeks apart, while the same user could view the password in the admin center. The Entra store that Windows LAPS uses returns 200 with no credentials for Macs. The conclusion is that macOS passwords are viewable only in the admin center today. The app shows the rotation date when the call succeeds, degrades cleanly when it does not, and offers rotation (POST .../rotateLocalAdminPassword, beta) behind the toggle. I have an open question with Microsoft about this on Microsoft Q&A, and two read-only PowerShell scripts in the repository reproduce the findings against any tenant.

PIM

Then the claims challenge. The documented form is a 401 with a WWW-Authenticate header carrying base64-encoded claims. Under a PIM policy that requires a Conditional Access authentication context, Graph answers something else entirely: 400, error code RoleAssignmentRequestAcrsValidationFailed, with the claims sitting as raw JSON inside the error message string.

RoleAssignmentRequestAcrsValidationFailed claims={"access_token":{"acrs":{"essential":true,"value":"c1"}}}

Anything watching 401 and 403 for challenges never fires, and activation dies as a bare bad request. I could not find this documented. The app parses both forms, treats the blob as untrusted input because it is going straight into MSAL (it must be a small JSON object or it is dropped), hands it over as a claims request, re-authenticates, and retries once. A plain 401 deliberately never takes that path, or you loop people behind a prompt they cannot satisfy. And when the policy pre-read already says an authentication context is required, the claims go on the first attempt and the 400 is the fallback rather than the normal path.

Optional user names and error handling

Display names come from GET /v1.0/users?$filter=userPrincipalName in (...), fifteen UPNs per request because that is the most the in operator takes, off by default because it adds a permission to every customer's consent screen. Errors are mapped to specific states with their own copy: role missing (which offers PIM if enabled), consent required (which offers the link), throttled, service unavailable, device not Entra-joined. Graph's request-id header from the last failed call is captured because it is the first thing Microsoft support asks for, and the diagnostics report carries request IDs, AADSTS codes, correlation IDs and HTTP statuses through an allowlist, never a message string, because MSAL error descriptions can carry URLs and a broker redirect URL can carry an authorization code.

5. On-device security

ConcernWhat the app does
Token storageMSAL's keychain cache in the app's private access group, this device only, after first unlock. Refresh is MSAL's and is serialised through the auth actor. A token leaves the device only to Microsoft.
The app's own Keychain itemsTenant list and reveal-meter ledger: kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Entitlement token: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Nothing syncs to iCloud Keychain.
Credential cachingNone. A credential lives in a SensitiveValue in memory for at most the sixty-second window, then is wiped. Nothing reaches disk, UserDefaults, state restoration or a backup. Credential requests use an ephemeral session with no cache and no cookie store.
Biometric gate.deviceOwnerAuthentication, so Face ID, Touch ID or passcode, before the Graph call. An abandoned prompt produces no audit event in the tenant.
Reveal windowSixty seconds with a countdown. Re-masked and wiped on expiry, hide, backgrounding and screen capture. Published to the screen only once the scene is active.
App switcherA privacy cover replaces the content in the multitasking snapshot. Verified on hardware for both credential types; two cleverer implementations failed in the dangerous direction and were reverted.
Screenshots and recordingiOS cannot block them. The app observes the screenshot and capture notifications, hides the credential, and recommends rotation. Stated as a limitation in the security policy rather than hidden.
ClipboardCopying a credential uses UIPasteboard with .localOnly, so no Universal Clipboard handoff, and an .expirationDate ninety seconds out. The app clears the pasteboard itself if the value is still there. Copying was gated behind the paid tier for one commit and reverted the same day: a user who cannot copy will retype or photograph the password, which is worse.
App lockOptional Face ID over the device list itself, because hostnames and primary users are reconnaissance for a whole tenant. Only backgrounding starts the clock, with a five-minute grace period and suppression during sign-in, so a round trip to Authenticator never locks the app mid-flow.
DiagnosticsIn memory only, deliberately not persisted. Typed fields through an allowlist with no place for a password, a key, a device name or a user name. The user can read the whole report before sending it.
TelemetryNone. No analytics, no crash reporting, no account. The privacy manifest declares one required-reason API, UserDefaults, for the app's own settings. The App Store label is Data Not Collected.
Free-tier meterA Keychain ledger of reveal timestamps, on the device only. It is a nudge, not DRM. A device wipe resets it, and the privacy claim is worth more than enforcement.

6. Security decisions and the threats behind them

DecisionThreat addressedResidual
Delegated permissions only; no app identity with tenant-wide readA vendor or attacker reading credentials without a human presentNone for the app; Microsoft RBAC governs
No server in the credential path; three-host egress, published and proxy-verifiableA compromised or malicious vendor backendLicensing endpoint exposure is limited to tenant GUIDs
Credential module isolation enforced at build timeA future change leaking a credential to a log, SDK or the licensing layerThe guard runs on every commit; it must keep running
SensitiveValue that cannot be serialised or described; no Codable in the credential pathAccidental serialisation, logging or UI echo of a secretNone known
Biometric gate before Graph; meter before the gate, charged afterA lost or borrowed unlocked phone; abandoned reveals polluting the tenant's audit logA fully compromised device can read what its user can read
Sixty-second window, wipe on background and capture, switcher coverA phone left on a bench with a password on screen; multitasking snapshotsScreenshots cannot be blocked on iOS
Tenant pin: token tid must equal the deliberately selected tenantCross-tenant authorization mistakes, especially for MSPsVery low; the comparison is unit-tested on every build
This-device-only Keychain classes, no syncToken or state replication to other devicesLow
Clipboard local-only with expiry and self-clearingUniversal Clipboard handoff; a stale credential in the pasteboardLow
Claims-challenge blob treated as untrusted inputAttacker-influenced content passed into MSALLow
Entitlement endpoint receives a tenant GUID only; the token is an ES256 JWS verified against compiled-in public keys, never a fetched key setBackend compromise; token forgery; licence replay across tenantsA stolen licence token is useful only inside the licensed tenant
Reveal count on the device, never on a serverThe vendor learning how often each tenant retrieves passwordsThe meter is evadable by design
Source published for reviewBlind trust in a credential-handling binaryNo reproducible builds on iOS

7. Challenges and dead ends

macOS LAPS has no API, and the one function 500s. The specification flagged this as the product-defining open question and it was settled against a live tenant on the second day of the project: no password in the contract, 500 in practice, nothing in the Entra store. macOS reveal was declared impossible rather than pending, the App Store listing says so up front, and the question is with Microsoft.

The first run on a real phone found two auth bugs the simulator cannot show. With Microsoft Authenticator installed, the broker's redirect arrived as a URL open that nothing handled, and MSAL reported that the application did not receive a response from the broker. Separately, MSAL's interactive calls were being made from an actor on the cooperative thread pool, and MSAL touches the presenting view controller inside acquireToken. Without a broker, MSAL keeps the whole flow in-process and the fragile part never runs, so the simulator was green. Both bugs produced the same generic error. I wrote two fixes before reading a log, and both were wrong; the MSAL log named the cause in one line once I captured it. The lesson stuck: for a device-only failure, read the console before writing code.

The MSAL module is invisible to the package tests. Its whole implementation is inside #if os(iOS), so a file added without that guard broke the macOS test build while the iOS app stayed healthy, and later a wrong MSAL API label passed 262 green tests and failed the iOS build immediately. The two checks cover opposite halves, and the project now runs both before any commit.

The tenant guard was wrong for MSPs. The original check compared the token's tid to the account's home tenant, which is right for a single-organisation admin and breaks an MSP operating in a customer tenant. Deleting the check would have removed the only thing stopping a token for the wrong organisation being used against it. The fix made the expectation explicit: the token must match the tenant the app deliberately selected.

Testing MSP switching and PIM needed licences and a second tenant. PIM needs Entra ID P2, which I bought for the test tenant. Switching needs a second tenant that has invited the account; building one from scratch was investigated and abandoned in favour of a guest invitation. Because the real path depends on tenant policy nobody can reproduce in a lab, each of the switcher's ten failure cases explains itself on screen.

The claims challenge that arrived as a 400, described in section 4. Activation under an authentication-context policy died as a bare bad request until the error body was parsed.

Provisioning versus pending approval. The first PIM build reported an ordinary provisioning delay as waiting for approval, in a tenant with no approval step, and sent someone looking for an approver who did not exist.

The privacy cover flashed, and two fixes exposed the credential. After Face ID the scene is still inactive when the secret is published, so the Hidden cover showed for two seconds. Two attempts to track the transition more precisely both failed on hardware by showing the credential in the app switcher. The condition that errs toward hiding stayed, and the real fix was elsewhere: publish the credential only once the scene is active.

A search regression you could feel. Widening search used range(of:options:) with case and diacritic insensitivity, which performs full Unicode folding on every call, and called a ranking function from inside the sort comparator. On a tenant of a few hundred devices that was on the order of a hundred thousand folds per keystroke. Fold each field once, score once, sort on the precomputed rank.

Search only covered loaded pages. On a tenant with several pages of devices, a device that existed was not found. Hence the background fill.

Three bugs a 262-test suite did not catch surfaced in one afternoon on hardware: licence state leaking across tenants, no sign-out button anywhere in the UI, and an Activate button that did not name the organisation it would bind to. A green suite is not proof.

@Published emits in willSet. A sink that re-read the property saw the old value, so buying the MSP subscription did not show the tenant switcher until relaunch. Use the value the closure hands you.

Two subscription groups became one. Separate groups would have allowed double-paying, two free trials and no upgrade path.

App Store review rejected the first submission for a missing EULA link in the description. Guideline 3.1.3 shaped the website more than the app: the support page the app links to carries no pricing and no purchase links. iPad was removed from 1.0 rather than shipped as a stretched phone app, because the reveal path's scene-phase behaviour under multitasking needs its own verification on an iPad.

8. Timeline

From the public repository's history: 153 commits, first on 13 August 2026, release on 8 September 2026. The licensing backend lives in a separate repository and is not counted.

DateMilestone
13 AugFirst commit.
14 AugSecurity core (SensitiveValue, providers, auth seam, isolation guard), MSAL sign-in verified end to end, inventory with paging and search, biometric gate and reveal window, consent onboarding, device list and gated reveal. macOS LAPS verified impossible the same day.
17 AugBitLocker, settings, diagnostics export, privacy policy and terms, app icon, source-available licence.
26 to 27 AugFirst run on a real phone. Broker redirect and main-thread fixes. First TestFlight builds. Entitlement backend infrastructure provisioned.
28 to 29 AugPricing and metering decided: free tier metered on the device, never on a server. Product renamed. Licensing module and reveal meter.
1 SepEntitlement API contract written first, then implemented: signing key published, server retention tightened, tokens verified on device. Network transparency document and security one-pager.
2 SepMSP tenant switcher, tenant guard re-pointed, sign-out added, Entra ID P2 purchased, switching verified. PIM: claims challenge, directory roles and groups, policy read, the 400 form, provisioning versus approval.
3 SepBackground inventory fill, optional display names, LAPS password history, Apple subscriptions, favourites and recents, session restore, app lock, support page, site moved to Cloudflare Pages. 1.0 submitted.
4 to 5 SepReview rejection fixed. Organisation licensing automated end to end.
8 Sep1.0 released on the App Store. Microsoft Q&A question filed. Source tagged.

Twenty-one days from first commit to submission, twenty-six to release, with 379 package tests at release.

9. What I would do differently, and what is next

Differently

Next

LAPSlock is on the App Store, and the client source is published for review on GitHub under a source-available licence. The how it works page is the plain-language version of this post, including the permissions table. Corrections and findings to connor@kainor.com; the security policy explains how to report a vulnerability. Kainor LLC is not affiliated with, endorsed by, or sponsored by Microsoft Corporation.