Swift auth security audit suite
Skill esaldgut/ai-native-engineering-workspace/global-skills/apple-auth/swift-auth-security-audit-suite
Build a security-audit test suite for an iOS auth subsystem that thinks like an attacker — token-leakage scans (UserDefaults / pasteboard / os_log), ATS validation (no NSAllowsArbitraryLoads), injection and boundary inputs, anti-enumeration (no provider-specific "user not found" leaks for ANY OAuth/OIDC provider), OAuth `state` CSRF + constant-time comparison, concurrent-refresh single-flight, and first-launch Keychain-cleanup idempotency. Built with Swift Testing (@Suite/@Test, Issue.record for soft audit findings). Use when hardening or reviewing the auth/network/storage layers.From its SKILL.md
npx -y skills add esaldgut/ai-native-engineering-workspace --skill swift-auth-security-audit-suiteAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.7 KB, ~2.4k tokens by cl100k_base, as published. Nobody here has run it
Auth security audit suite (iOS, Swift Testing)
A functional auth flow can still leak secrets, enumerate users, or race itself into a corrupt token state. This skill builds a security-audit test suite that exercises the auth subsystem the way an attacker would: it scans for stored-secret leaks, validates App Transport Security, throws hostile inputs at validators, proves the error mapper can't be used for user enumeration, and asserts the concurrency and install-hygiene invariants. It is provider-agnostic — the anti-enumeration rule holds for Cognito, Firebase, Auth0, Okta, or any OAuth/OIDC backend.
When to invoke
- You have an auth subsystem (token store, OAuth flow, error mapper) and want a repeatable security gate in CI rather than a one-time manual review.
- A review flagged a possible secret leak (a token in a log line, a
UserDefaultskey, a pasteboard copy) and you want a regression-locking test. - You're wiring federated/OAuth sign-in and need to prove
stateis single-use and compared in constant time, and that the provider's error codes never reach the UI.
Announce on invoke: "Using swift-auth-security-audit-suite to add attacker-perspective auth tests (leakage, ATS, anti-enumeration, OAuth state) per OWASP MASVS-AUTH + RFC 9700."
Do not treat this suite as a substitute for server-side controls. Client tests are
defense-in-depth; the provider must also be configured to suppress user-existence errors
(e.g. Cognito PreventUserExistenceErrors=ENABLED). Both layers are required.
What the suite must cover (verified rationale)
| Audit area | Assertion | Source |
|---|---|---|
| Token leakage — storage | No UserDefaults key whose name/value looks token-like; tokens live only in Keychain | OWASP MASVS-STORAGE |
| Token leakage — pasteboard | UIPasteboard.general.string doesn't echo a secret after a copy event | MASVS-STORAGE |
| Token leakage — logs | No os_log call interpolates a token as %{public}@; default %@ is .private | OSLogPrivacy |
| ATS | NSAllowsArbitraryLoads / …InWebContent not true; no TLS-min below TLSv1.2 without a justified NSExceptionDomains entry | MASTG-KNOW-0071 |
| Injection / boundary | Username/redirect validators reject javascript: schemes, NUL bytes, U+202E RTL override, over-length input | MASVS-CODE |
| Anti-enumeration | The error mapper never surfaces USER_NOT_FOUND / auth/user-not-found / "does not exist"; collapses to one generic credential error | provider docs (generalized) |
OAuth state CSRF | Two parallel flows produce different state; a tampered state on callback is rejected; comparison is constant-time | RFC 9700 / RFC 6749 §10.12 |
| Concurrent refresh | N parallel accessToken() calls → exactly one network refresh, all callers get the same token | MASVS-AUTH |
| First-launch hygiene | Fresh install (missing UserDefaults sentinel) purges stale Keychain items; idempotent across launches | Apple Keychain (survives uninstall) |
The rules
1. Anti-enumeration is provider-agnostic — assert the mapped output, not the raw code
Every provider has a distinguishing "no such user" signal: Cognito UserNotFoundException, Firebase
auth/user-not-found, Auth0 invalid_grant with a user-not-found description. The UI must receive
one generic case ("Incorrect username or password"). The test iterates the data-layer error enum
and asserts no case's user-facing message contains "not found", "does not exist", "unknown user",
or a raw provider code. (The server must also suppress these — e.g. Cognito PreventUserExistenceErrors=ENABLED.)
2. state is required even with PKCE — and compared in constant time
RFC 9700 lets PKCE provide CSRF protection only when the authorization server supports it; otherwise
state is required. Best practice: always send both. The audit asserts (a) state differs across
parallel flows, (b) a tampered callback state is rejected, (c) comparison uses
HMAC.isValidAuthenticationCode or XOR-accumulate — never == on the raw value.
3. Issue.record for sweep findings; #expect for hard invariants
For "scan everything and report all leaks" passes (UserDefaults dump, log grep), use
Issue.record(...) so one offending key doesn't abort the sweep. For binary invariants (ATS has no
arbitrary loads; refresh is single-flight) use #expect.
4. Keychain survives uninstall — the first-launch purge is mandatory and must be idempotent
iOS keeps Keychain items across app uninstall by design. Test that a missing first-launch sentinel triggers a purge, and that running the hook twice leaves the same state (no crash, no double-delete error).
Canonical example
import Testing
import Foundation
@testable import MyApp // @testable import last
@Suite("Auth security audit")
struct AuthSecurityAuditTests {
@Test func noTokenLikeKeysInUserDefaults() {
for (key, value) in UserDefaults.standard.dictionaryRepresentation()
where key.lowercased().contains("token") || key.lowercased().contains("secret") {
Issue.record("Secret-like UserDefaults key leaked: \(key) = \(value)") // soft: report all
}
}
@Test func atsHasNoArbitraryLoads() {
let ats = Bundle.main.object(forInfoDictionaryKey: "NSAppTransportSecurity") as? [String: Any] ?? [:]
#expect(ats["NSAllowsArbitraryLoads"] as? Bool != true)
#expect(ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool != true)
}
@Test(arguments: ["javascript:alert(1)", "user\u{0000}name", "admin\u{202E}", String(repeating: "a", count: 5_000)])
func validatorRejectsHostileInput(_ input: String) {
#expect(UsernameValidator.isValid(input) == false)
}
// Anti-enumeration — provider-agnostic. No raw provider code reaches the UI.
@Test func errorMapperNeverLeaksUserExistence() {
for mapped in AuthError.allCasesForTesting.map(AuthErrorPresenter.message(for:)) {
let m = mapped.lowercased()
#expect(!m.contains("not found"))
#expect(!m.contains("does not exist"))
#expect(!m.contains("user_not_found"))
}
}
@Test func oauthStateIsUniqueAndConstantTimeComparable() {
let a = OAuthState.generate(), b = OAuthState.generate()
#expect(a.value != b.value) // single-use, unguessable
#expect(a.matches(a.value)) // constant-time equal
#expect(!a.matches(b.value)) // tampered → reject
}
@Test func concurrentRefreshIsSingleFlight() async {
let session = MockAuthSession()
await withTaskGroup(of: String?.self) { group in
for _ in 0..<16 { group.addTask { try? await session.accessToken() } }
var tokens: [String] = []
for await t in group { if let t { tokens.append(t) } }
#expect(Set(tokens).count == 1) // all callers got the SAME new token
#expect(session.refreshCallCount == 1) // only ONE refresh hit the network
}
}
@Test func firstLaunchPurgeIsIdempotent() {
FirstLaunchHygiene.reset() // simulate fresh install
FirstLaunchHygiene.purgeStaleKeychainIfNeeded() // purges
FirstLaunchHygiene.purgeStaleKeychainIfNeeded() // no-op, no crash
#expect(FirstLaunchHygiene.didComplete)
}
}
Decision aid: CI gate vs runtime check
- CI gate (default): run the whole suite on every PR. ATS, anti-enumeration, and leakage scans belong here — they're cheap and deterministic.
- DEBUG runtime assertion: the UserDefaults/pasteboard scan can also fire on launch in DEBUG to catch leaks introduced between test runs; never ship it in RELEASE.
- UI-driven pasteboard test: the in-process
UIPasteboardcheck is enough for logic; only escalate to anXCUIApplicationkeyboard-driven test if a real copy button is the surface under audit.
Related skills
global-skills/apple-auth/swift-auth-security-checklist/SKILL.md— the defense-in-depth checklist these tests verify.global-skills/apple-auth/swift-post-quantum-security-ios26/SKILL.md— the constant-time comparison thestatetests rely on (HMAC.isValidAuthenticationCode).global-skills/apple-auth/swift-testing-framework-conventions-mvvm/SKILL.md— Swift Testing conventions (@Suite, import order,Issue.recordvs#expect).
Sources
- OWASP MASVS-AUTH · MASTG-KNOW-0071 ATS · OWASP Authentication Cheat Sheet
- OSLogPrivacy · UIPasteboard · Swift Testing @Suite
- AWS Cognito — Managing user existence error responses (one provider example of the generic rule)
- RFC 9700 OAuth 2.0 Security BCP · RFC 7636 PKCE
Last verified: 2026-06-03 against OWASP MASVS-AUTH / MASTG, OSLogPrivacy, Cognito
PreventUserExistenceErrors (exact name + ENABLED/LEGACY behavior confirmed), and RFC 9700.
The anti-enumeration rule was verified to generalize across Cognito/Firebase/Auth0.
Re-check after: WWDC26 + any CryptoKit/Security release, or by 2026-12-01. Decay risk: low
(RFC/OWASP-grounded; the testing API surface is stable).
Found a drift? Run /skill-pattern-freshness-audit apple-auth.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.