| name | swift-auth-security-audit-suite |
| description | >- Use when this capability is needed. |
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
UserDefaults key, a pasteboard
copy) and you want a regression-locking test.
- You're wiring federated/OAuth sign-in and need to prove
state is 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
@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)")
}
}
@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", , (repeating: , count: )])
( : ) {
#expect(.isValid(input) )
}
() {
mapped .allCasesForTesting.map(.message(for:)) {
m mapped.lowercased()
#expect(m.contains())
#expect(m.contains())
#expect(m.contains())
}
}
() {
a .generate(), b .generate()
#expect(a.value b.value)
#expect(a.matches(a.value))
#expect(a.matches(b.value))
}
() {
session ()
withTaskGroup(of: ?.) { group
{ group.addTask { session.accessToken() } }
tokens: [] []
t group { t { tokens.append(t) } }
#expect((tokens).count )
#expect(session.refreshCallCount )
}
}
() {
.reset()
.purgeStaleKeychainIfNeeded()
.purgeStaleKeychainIfNeeded()
#expect(.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
UIPasteboard check is enough for logic; only escalate
to an XCUIApplication keyboard-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
the state tests rely on (HMAC.isValidAuthenticationCode).
global-skills/apple-auth/swift-testing-framework-conventions-mvvm/SKILL.md — Swift Testing
conventions (@Suite, import order, Issue.record vs #expect).
Sources
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.
Source: esaldgut/ai-native-engineering-workspace — distributed by TomeVault.