
Removing a feature is harder than adding one — the story of a refactor done with Claude Code, and what replaced the user account
A few months ago I shipped VoxMap: an interactive map where each of France’s ~35,000 municipalities is colored by how it voted, fed by a pipeline that aggregates a dozen public data sources. Built alone, evenings and weekends, written with Claude Code. I wrote a first article about what building a production app with an AI actually looks like.
Then I looked at the numbers.
People opened the app, hit a sign-up screen — email, password, verification code, home town — and left. The data was public, but access required something private. To see how a village had voted, you had to create an account. It was absurd, and I had built it myself.
So the following week, I added nothing. I deleted.
Deleting means dismantling a part that holds up ten others
Adding a feature is writing new code in an empty corner. Removing one is discovering everything that leaned on it without you knowing.
In VoxMap, the user account was load-bearing. It wasn’t just for “logging in”: it was the identity that the premium subscription, the push tokens, the preferences, the home town, the unlocking of enriched data, the device limit, the email verification and the biometric lock all hung from. Pull that thread and ten others come with it.
The real difficulty isn’t deleting thirty-one files. It’s answering, for each of those ten threads, the question: “so what does this attach to now?”
- The premium subscription was tied to a
user_id. What do you tie it to when there is no user anymore? - The server had to stop people from scraping its data in a loop. Without accounts, what distinguishes a genuine app from a script that mints 10,000 fake identities?
- GDPR deletion went through an email to the DPO. Without an email, how does someone ask for their data to be erased?
Each of those questions is a small project. End to end, it adds up to 3,500 lines removed — and considerably more written elsewhere to keep the promises the account used to keep. That’s the paradox of deletion: the net balance is negative, but you worked more than you would have to add.
Where the AI changes the game isn’t typing speed. It’s that it holds the dependency map while you take things apart. “Remove the login screen” isn’t an instruction: it’s an investigation across forty files to find everything that assumed a user existed. An AI that reads the whole repository at once misses fewer threads than a human at 11 pm.
What “no account” actually changed
For those who like to see the part laid out on the bench, here are the ten threads, before and after.
- Identity. Before: email, password, verification code, biometric unlock. After: an installation identifier — a UUID the app generates on first launch and keeps in the phone’s keychain. It names an installation, never a person.
- Session. Before: a login that returned a token. After:
POST /auth/anonymous, carrying an attestation proof (more on that below), which returns a one-hour token and a refresh token. Nothing to type, nothing to remember, nothing to steal. - Abuse. The login was also my wall against scraping. After: rate limiting per IP address on authentication, per token bearer on data — and above all attestation, which makes minting identities expensive.
- Premium. Before: tied to the
user_id. After: tied to the anonymous identity the payment provider already assigns to every installation, which the app reports to the server. - Notifications. One push token per installation, not per user.
- Home town. Before: stored in the database, attached to the account. After: on the phone, and nowhere else. The server no longer knows it.
- GDPR deletion. Before: an email to the DPO. After: a button, an endpoint, immediate effect.
- Device limit, email verification, biometrics. Removed. They only made sense to protect an account.
- Administration. The “Users” tab became a “Devices” tab: you see installations, versions, dates — not people.
- The tally. Thirty-one files deleted, 3,500 lines gone; a few hundred added for attestation. The app does the same thing it did before. It just asks for a lot less.
What replaced the account: prove the app, not the user
Here is the heart of the problem. An account isn’t only friction — it’s also proof. The password proved the request really came from the person. By removing it, I was losing my only defense against abuse: what now stops anyone from hitting my API and draining its data, or generating identities without limit?
The answer isn’t to identify the person. It’s to prove that the request comes from a genuine instance of my app, installed from an official store, on a real device — without ever knowing who holds it.
It has a name: app attestation. App Attest at Apple, Play Integrity at Google. The operating system cryptographically signs a statement that “this app is yours, unmodified, on an authentic device”. The server verifies that statement before opening an anonymous session.
The protocol has three beats, identical on both platforms. The app asks the server for a challenge — thirty-two random bytes, valid for two minutes, single-use. The OS produces a proof bound to that challenge. The server verifies the proof, consumes the challenge, and opens the session only if everything checks out. The challenge is what makes the attestation non-replayable: an intercepted proof is worthless without it, and it only works once.
<code>public (Guid Id, byte[] Challenge) Issue()
{
var id = Guid.NewGuid();
var challenge = RandomNumberGenerator.GetBytes(32);
cache.Set(Key(id), challenge, TimeSpan.FromMinutes(2));
return (id, challenge);
}
<em>// Consumed on first verification: a replayed token</em>
<em>// no longer finds its challenge.</em>
public byte[]? TryConsume(Guid id)
{
if (!cache.TryGetValue(Key(id), out byte[]? challenge)) return null;
cache.Remove(Key(id));
return challenge;
}</code>
Code language: PHP (php)
On paper, elegant. In practice, this is where the evening got long.
iOS: App Attest, by hand
On the app side, Apple provides DCAppAttestService. On first contact, the app creates a key inside the Secure Enclave and asks Apple to attest it; on subsequent launches, it only produces an assertion signed by that key. In both cases, what it signs is the hash of our challenge.
var challenge = await http.GetFromJsonAsync<ChallengeResponse>("/auth/attestation-challenge");
var clientDataHash = NSData.FromArray(
SHA256.HashData(Convert.FromBase64String(challenge.Challenge)));
var keyId = Preferences.Get("AppAttestKeyId", string.Empty);
if (!string.IsNullOrEmpty(keyId))
{
<em>// Subsequent launches: an assertion signed by the Secure Enclave key.</em>
var assertion = await DCAppAttestService.SharedService
.GenerateAssertionAsync(keyId, clientDataHash);
return Envelope("assert", challenge.ChallengeId, keyId, assertion);
}
<em>// First contact: a fresh key, attested by Apple.</em>
var newKeyId = await DCAppAttestService.SharedService.GenerateKeyAsync();
var attestation = await DCAppAttestService.SharedService
.AttestKeyAsync(newKeyId, clientDataHash);
Preferences.Set("AppAttestKeyId", newKeyId);
return Envelope("attest", challenge.ChallengeId, newKeyId, attestation);
Code language: JavaScript (javascript)
On the server side, App Attest can’t be verified with a simple API call. You have to decode a CBOR object, validate an X.509 certificate chain up to Apple’s root, recompute a nonce by hashing, check that it matches a specific extension of the certificate, then keep an anti-replay counter per device. And surprise: no maintained .NET library does this. I looked — nothing. Kotlin, Go, Rust have their implementations. .NET doesn’t.
So we wrote it by hand. A complete verifier, from Apple’s documentation, with the platform’s raw cryptographic primitives. Condensed, the initial attestation looks like this:
<em>// 1. CBOR: { fmt: "apple-appattest", attStmt: { x5c }, authData }</em>
var (fmt, x5c, authData) = ReadAttestationCbor(cbor);
if (fmt != "apple-appattest" || x5c.Count < 2) return false;
<em>// 2. Certificate chain up to the "Apple App Attestation Root CA",</em>
<em>// embedded in the binary: validation never depends on a download.</em>
using var leaf = X509CertificateLoader.LoadCertificate(x5c[0]);
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(appleRoot);
foreach (var extra in x5c.Skip(1))
chain.ChainPolicy.ExtraStore.Add(X509CertificateLoader.LoadCertificate(extra));
if (!chain.Build(leaf)) return false;
<em>// 3. nonce = SHA256(authData ‖ clientDataHash), expected in an extension</em>
<em>// of the leaf certificate (OID 1.2.840.113635.100.8.2).</em>
var nonce = SHA256.HashData([.. authData, .. clientDataHash]);
var ext = leaf.Extensions["1.2.840.113635.100.8.2"];
if (ext is null || !ExtensionContainsNonce(ext.RawData, nonce)) return false;
<em>// 4. authData: rpIdHash = SHA256("TEAMID.bundleId"), initial counter 0,</em>
<em>// credentialId = keyId.</em>
var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(appId));
if (!authData.AsSpan(0, 32).SequenceEqual(rpIdHash)) return false;
<em>// 5. The leaf certificate's public key will sign every future assertion:</em>
<em>// store it for this installation.</em>
device.AppAttestPublicKey = leaf.GetECDsaPublicKey()!.ExportSubjectPublicKeyInfo();
device.AppAttestCounter = 0;
Code language: JavaScript (javascript)
Then, on every launch, the assertion. It’s shorter to verify, but it carries the most important piece of the whole scheme: the counter. Apple increments it with every assertion; if it doesn’t strictly increase, someone is replaying a token.
<em>// CBOR: { signature, authenticatorData }</em>
var nonce = SHA256.HashData([.. authData, .. clientDataHash]);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(device.AppAttestPublicKey, out _);
if (!ecdsa.VerifyData(nonce, signature, HashAlgorithmName.SHA256,
DSASignatureFormat.Rfc3279DerSequence))
return false;
<em>// A counter that doesn't increase = a replay.</em>
long counter = BinaryPrimitives.ReadUInt32BigEndian(authData.AsSpan(33, 4));
if (counter <= device.AppAttestCounter) return false;
device.AppAttestCounter = counter;
Code language: PHP (php)
This is exactly the kind of task where an AI is at its best: a closed, specified, verifiable problem. Apple’s spec says what must happen, step by step; each step is a refusal if it fails. No product ambiguity, no “it depends on taste”. Claude wrote the CBOR validation, the certificate chain construction, the nonce check; I read every line against the docs and supplied the context it couldn’t guess — my Team ID, the attestation environment, the shape of my tokens.
Android: Play Integrity, with Google in the middle
On Android the work is split differently: the token is opaque, and it’s Google’s API that decodes it for you. What remains on your side is binding the token to your challenge — the famous nonce — and then reading the verdicts.
var challenge = await http.GetFromJsonAsync<ChallengeResponse>("/auth/attestation-challenge");
<em>// The nonce binds the token to OUR challenge: base64url, no padding.</em>
var nonce = Convert.ToBase64String(Convert.FromBase64String(challenge.Challenge))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var manager = IntegrityManagerFactory.Create(Application.Context);
var request = IntegrityTokenRequest.InvokeBuilder().SetNonce(nonce).Build();
var response = await manager.RequestIntegrityToken(request)
.AsAsync<IntegrityTokenResponse>();
return Envelope(challenge.ChallengeId, response.Token());
Code language: JavaScript (javascript)
On the server, we have Google decode the token with the project’s service account, then check — in this order — that it answers our challenge, targets our package, is fresh, that the app is the one from the Play Store, and that the device is sound.
var payload = await DecodeIntegrityTokenAsync(envelope.Token); <em>// POST …/{package}:decodeIntegrityToken</em>
var details = payload.GetProperty("requestDetails");
if (details.GetProperty("nonce").GetString()?.TrimEnd('=') != expectedNonce) return false;
if (details.GetProperty("requestPackageName").GetString() != packageName) return false;
if (Math.Abs((DateTimeOffset.UtcNow - issuedAt).TotalMinutes) > 10) return false;
<em>// The app really is the Play Store build, unmodified…</em>
var app = payload.GetProperty("appIntegrity").GetProperty("appRecognitionVerdict").GetString();
if (app != "PLAY_RECOGNIZED") return false;
<em>// … on a device that passes integrity.</em>
var verdicts = payload.GetProperty("deviceIntegrity").GetProperty("deviceRecognitionVerdict");
if (!verdicts.EnumerateArray().Any(v => v.GetString() == "MEETS_DEVICE_INTEGRITY")) return false;
Code language: JavaScript (javascript)
One detail that matters: a development build installed by hand comes back as UNRECOGNIZED_VERSION, and the server refuses it. That’s intentional. Test devices go through an explicit list, maintained by an administrator — never through an exception in the code.
Shadow mode
The detail I’m proudest of isn’t the crypto. It’s shadow mode — and it deserves more than a paragraph, because it’s the part that made everything above deployable.
The problem with attestation is that it sits at the front door and it’s all-or-nothing. If my verifier has a bug — one byte off in the authData layout, a certificate chain I build wrong, a nonce encoding that differs between iOS versions — then on launch day every real user is locked out. Worse: from the server’s logs, a bug in my code and an attack look exactly the same. Both are refusals. You can’t tell them apart until you’ve seen the verifier run against real devices, and you can’t see that without deploying it, and deploying it means enforcing it. That’s the trap.
The way out is to separate verifying from enforcing. One server flag, set only on the test environment. When it’s on, the server still runs the full verification whenever a token is present — CBOR, chain, nonce, counter on iOS; Google’s decode and verdicts on Android — and writes the verdict to the logs, with the platform and the installation id. Then it lets the request through regardless of the result. Production never sets the flag. Production stays fail-closed.
if (allowAll) <em>// test environment only — production never sets this flag</em>
{
if (token is not null && verifiers.TryGetValue(platform, out var shadow))
{
var verdict = await shadow.VerifyAsync(token, installationId, ct);
log.LogInformation("Attestation SHADOW {Platform}: {Verdict}",
platform, verdict ? "OK" : "REFUSED");
}
return true; <em>// observe, never refuse</em>
}
if (bypass) return true; <em>// test device registered by an administrator</em>
if (string.IsNullOrWhiteSpace(token)) return false; <em>// fail-closed</em>
return await verifiers[platform].VerifyAsync(token, installationId, ct);
Code language: JavaScript (javascript)
What this bought me is a rehearsal on real hardware with zero risk. Every real device that talked to the test environment — a TestFlight iPhone, a Pixel from Play’s internal-testing track, development builds from the IDE — exercised the whole chain and left a verdict in the logs, and nobody was ever blocked while I read them. And the logs taught me things the spec hadn’t.
They showed me the assertion path working end to end: on the second launch of a real iPhone, the counter went from 0 to 1, and the server accepted it — the anti-replay mechanism, live, not on paper. They showed me a real Pixel coming back from Google with PLAY_RECOGNIZED and MEETS_DEVICE_INTEGRITY, the two verdicts I had decided to require.
They also showed me two classes of refusal, and both turned out to be correct. The iOS simulator can never attest: DCAppAttestService simply reports itself as unsupported there — not a bug, a fact of the platform. And an Android build sideloaded from the IDE, outside the Play Store, comes back as UNRECOGNIZED_VERSION — which is precisely what attestation is for. Had I enforced from day one, I’d have discovered both by locking myself out of my own app. Instead I could decide, calmly, how to handle them: an explicit bypass list of test devices, edited by an administrator, so that the code never contains an exception. That’s the bypass line above, and it’s the only permissive path in the whole router.
There was an operational lesson too. On the test environment, every deploy from the IDE regenerates the installation id — the development keychain doesn’t survive it. Requiring a registration per build would have made the environment unusable within a day. That’s why the flag lives there permanently, and why it lives only there.
The switching rule is simple to state: when the logs show nothing but “OK” for every class of real device you intend to support, you’re done rehearsing. Production, which never had the flag, enforces from its first deployment — and it did. And if something ever went wrong in production, relaxing it is one configuration change away, not a rebuild and a store review.
The cost of all this? About fifteen lines in a router. The decision to build it, when to trust the logs, what risk to accept on launch day — that’s an engineer’s judgment, not a line of code, and that kind of judgment stays, entirely, mine.
When the first “attestation OK” appeared in the production logs for a real iPhone, then “verdicts OK” for a real Pixel, with no bypass of any kind — the security I had lost by deleting the account was back. Differently. Better.
Premium without an account, and deletion turned into a feature
Two corollaries, shorter.
The subscription. Without a user_id, what do you attach a premium to? To the anonymous identity the payment provider (RevenueCat) already assigns to every installation. The app reports that identifier to the server, which checks the entitlement with the provider’s API. Unexpected benefit: when an Android user reinstalls the app, their subscription is transferred automatically to the new identity — without doing anything. One less friction, again.
GDPR deletion. Before, you had to write to the DPO. Now it’s a button in the settings: “Delete my data”. One tap, immediate effect, everything the server knows about the device is erased and the app starts over. The endpoint fits in four lines, and that is precisely what makes it credible: there isn’t much to erase.
<em>// DELETE /api/devices/me — everything the server knows about this installation, and nothing else.</em>
await db.DeviceTokens.Where(t => t.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.NotificationPreferences.Where(p => p.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.RefreshTokens.Where(r => r.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.Devices.Where(d => d.InstallationId == installationId).ExecuteDeleteAsync(ct);
return Results.NoContent();
Code language: JavaScript (javascript)
The regulatory constraint became a feature — and, incidentally, an argument: your lookups are tied to no identity, and you can erase everything with one tap. On politically sensitive data, that counts.
What deleting taught me about AI
Three things, honestly.
An AI is excellent at closed problems and mediocre at open ones. Writing an App Attest verifier from a spec: perfect. Deciding whether a shadow mode is needed, when to switch, what risk to accept: mine. The boundary isn’t “hard code / easy code”. It’s “specified / to be arbitrated”. It holds the map; I choose the route.
Deletion is the best test of how well a repository is understood. Adding, you can do blind. Removing cleanly requires knowing what leans on what. An AI that reads all the code at once excels where a tired human forgets a thread — and a forgotten thread, in a deletion, is a crash at startup.
It is confidently wrong, and you have to look. One evening, after a test purchase, the premium data didn’t show up right away. The obvious suspect was the purchase flow — and the AI followed me eagerly in that direction, because I was leading it there, proposing fix after fix that all looked right. The real culprit was elsewhere: a server cache patiently serving a stale “not subscribed”. The lesson isn’t new: an AI amplifies your hypothesis, the right one and the wrong one alike. Look at the whole system, not only at the place you already suspect.
VoxMap now opens with no account, no sign-up, no email. Maps and results available on first launch. The data is public; it no longer demands anything private. And the app is, I believe, safer than before — because it now proves its own authenticity instead of delegating it to a password.
A week of deleting. Three and a half thousand lines fewer. It’s the best work I’ve shipped on this project.
Built by one person. Written with Claude. Reviewed, line by line, by me.
VoxMap is free on iOS and Android — now without an account. To follow what comes next, there’s a newsletter.



