Table of Contents
- The problem
- What went wrong, commit by commit
- Design decisions and alternatives
- Testing a design meant to resist disguise
- Usage
- Limits
The problem
iOSSecurityTools is a Swift package of security building blocks for apps on iOS, macOS, tvOS, watchOS and visionOS: keychain storage, authenticated encryption, PIN hashes, Secure Enclave keys, certificate pinning, one-time passwords, device integrity checks and log redaction. It has no dependencies. Version 2.0.0, released on 15 October 2026 from PR #20, rebuilt the package from scratch; the 1.0.0 code base of February 2026 had been replaced with placeholder code in June 2026, and nothing from it remains.
This article is about one of those blocks, SensitiveDataRedactor, because it is the one that went wrong. Its job is narrow: a developer logs a request, a response, a header or some user text, and a password, a token or a card number is in it by accident. The redactor scrubs that text before it reaches a log, a trace or an analytics event.
The 2.0.0 redactor worked like most redactors do. It looked for secret field names such as password, token or api_key, and hid the value after them. The 2.0.0 changelog says it "normalizes zero-width characters, combining marks, full-width forms and non-ASCII digits before matching" and "finds secret words inside longer field names". That turned out not to be enough. The security note of 2.1.0 is blunt: "SensitiveDataRedactor in 2.0.0 could be bypassed, and 2.0.0 should not be relied on to keep secrets out of logs."
What went wrong, commit by commit
The work for 2.1.0 happened in PR #25, 242 commits on top of 2.0.0. Its history shows a design being abandoned, which is the useful part.
The first fixes, on the morning of 15 October, were the kind a deny-list invites. 26ee844 hid the value after :=, ?= and +=; 9d91ea8 after == and !=; 99166c6 after ===, :== and other separator runs. Each commit closed a few spellings and left the next ones open.
Then came look-alike letters. 2433c23 lists inputs that printed their value: pаssword=hunter2 with a Cyrillic а, tοken=hunter2 with a Greek ο, the same word in mathematical bold letters, a circled ⓟ, a precomposed accent. The fix was a new LatinFolding type with a table that read Cyrillic, Greek, Armenian and Cherokee look-alikes as Latin letters. Four minutes later, c1c5467 admitted the gap in its own message: "Reading look-alike letters as Latin only helps for the pairs in the table." A Cyrillic д in passwдrd has no Latin twin, so the name was split and neither half matched.
Ninety minutes after the table arrived, it was deleted. 7fc0753 records that "23 of 51 probe inputs still got a secret through" on that branch, and draws the conclusion: "A table of look-alikes is never complete, so the rule is turned around." The same reasoning reached free text on 20 October in 7a615e8: "Free text has no grammar, so matching secret names in it could always be bypassed: every round of look-alike letters, separators and escapes found another gap. The default is now safe: what the redactor does not fully understand is hidden, never shown."
The fixes had first been collected as an untagged 2.0.1. Because the redesign adds API and changes behaviour, f6719aa folded that section into 2.1.0 instead. The lesson is not that the first version had bugs. It is that guessing every way a secret name can be spelled does not converge, and that the cheaper answer was to stop guessing.
Design decisions and alternatives
Compare only plain-ASCII keys; hide every other value. A key of JSON, a form, a multipart body or a query is compared with the secret names only when it is made of A–Z, a–z, 0–9, _, -, . and spaces. Anything else, such as letters from another script, marks, format characters, brackets or escapes, has its value hidden without asking what the key says. The alternative was the table, and the history above shows why it lost. The cost is written into the guide: статус, user[id], passwordHint and token_type lose their values too.
Parse structured input, and omit what does not parse. redact(body:contentType:) chooses a parser by content type: JSON, application/x-www-form-urlencoded, multipart form data and plain text. The package's own JSON reader records where every value sits, and its result is used only when JSONSerialization reads the same tree; keys are compared after their escapes are decoded, and the output stays valid JSON. A body that is not UTF-8, has another type such as XML, does not parse completely or is larger than maximumBodySize is written as [body omitted: N bytes, <type>]. So is a body that a common server parser (PHP, Werkzeug, Rack, Go or busboy) could split into fields differently, because a field the redactor never saw could still carry a value. The default limit is 256 KiB; the changelog notes that the worst shapes tested take about 40 bytes of memory per byte of body.
Treat URLs as structure too. redact(url:) reads a URL with URLComponents and drops the user name, password and fragment whatever kinds holds. Query values are kept only for names on allowedQueryParameters, which is empty by default, and identifier-like path segments become :id. URLDetail.hostOnly records only scheme, host and port.
Cut free text at its first trigger. A log line has no grammar, so the redactor does not try to find where a value ends. In free text, a value is kept only after a key on allowedTextKeys (by default status, method, path, duration_ms and count). The first character outside a safe alphabet, an escape, a word longer than 256 characters, a key that is not allowed, a markup tag or a secret name hides everything after it. The README is honest that "Over-hiding is by design."
The rest of the package follows one rule: call Apple's frameworks (Security, CryptoKit, CommonCrypto, LocalAuthentication) directly and implement only what they lack. Errors are typed, for example throws(KeychainError) and throws(CipherError). There is no ptrace deny-attach or method swizzling, because those break between OS releases and are easy to bypass.
Testing a design meant to resist disguise
The inputs that got a secret past the earlier designs are kept as tests. The 2.1.0 changelog lists the 51 inputs of the second review, 386 adversarial probes and 453 probes from a review of the redesign. Commit messages number the reviews up to the twenty-sixth, and their probes are collected in one resource, Tests/iOSSecurityToolsTests/Resources/ReviewProbes.jsonl, with 7,866 entries. Property tests generate disguised secret names and random keys that are not allowed, and check that no value shows.
The redesign also changed how disguised names are read. c986d38 compares a word with the secret names by its ASCII skeleton: letters without an ASCII form are left out, so piꞏn reads as pin. Its message accepts the side effect openly: the Turkish word yapın reads as pin and hides what follows it.
Usage
Add the package with Swift Package Manager (from: "2.1.0") and import iOSSecurityTools. This sample is from the README; the README copies it from Examples/SecurityLab/SecurityLab/ReadmeSamples.swift, which both the example app and the package's tests compile, and a test checks both outputs:
1struct LoginRequest: Codable {2 var username: String3 @Redacted var password: String4}5 6func redactionSample() -> (request: String, log: String) {7 let request = LoginRequest(username: "alice", password: "hunter2")8 let printed = "\(request)" // LoginRequest(username: "alice", _password: <redacted>)9 10 let redactor = SensitiveDataRedactor()11 let log = redactor.redact("charged 4242 4242 4242 4242 to alice@example.com, token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln")12 // "charged •••• 4242 to a•••@example.com, token=[redacted]"13 return (printed, log)14}@Redacted keeps a property out of descriptions. The redactor keeps the last four digits of a card and the first letter and domain of an email address, and hides the token. For URLs, the logging guide gives this example, which a test also checks:
1redactor.redact(url: URL(string: "https://bob:pw@example.com/users/12345678/reset?code=x#y")!)2// https://example.com/users/:id/reset?code=[redacted]Limits
The threat model is narrow on purpose. The redactor is built for secrets logged by accident, not for someone who writes a log line to sneak a secret past it. The README calls it "a safety net, not a guarantee". In free text, a name written without a separator with two or more of its letters replaced by look-alikes (pɑsswoʀd hunter2) is not guaranteed to be found. Structured key names are shown, so a secret typed into a key ({"password hunter2":…}) shows.
Three known limitations ship with 2.1.0 and are tracked as issues. #22: a name with a control character hides the members beside it. #23: token and secret count only as a word or at the end of a key, so tokenvalue shows its value; mark such properties with @Redacted. #24: a short string of control characters hides every member beside it. Two of them hide too much; #23 shows a value.
Default-safe also costs size. The redaction folder grew from 718 lines in 2.0.0 to 5,697 lines in 33 files, about two thirds of the package's 8,488 lines of Swift source. Bodies over 256 KiB are omitted unless the app raises maximumBodySize, and raising it costs memory.
Outside redaction, IntegrityInspector reports signals, not proof, and its guide points to App Attest, which the package does not wrap yet; an App Attest wrapper is on the roadmap. BiometricAuthenticator is not available on tvOS and watchOS. The toolchain needs Swift 6.2 and Xcode 26 or later. Anyone on 2.0.0 who relies on the redactor should upgrade.
Source code: github.com/muhittincamdali/iOSSecurityTools
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy
