Connecting a Google account to my SwiftUI app took about a week, and almost none of that week went into the code that actually ended up in the app.

What I was building

Token Meter is an iOS app that shows how much of your quota you have left across the AI services you pay for. One card per account, a colored bar per usage window, and a home screen widget so you can see it without opening the app. It is a SwiftUI port of an Android app, built as a learning project, with no third party dependencies and nothing leaving the device except the requests to each provider.

By the time I got to Google Drive I already had OpenRouter working (paste an API key) and Claude working (sign in through a web view and capture the session cookie). Drive was supposed to be the third one, and on paper it looked like the easiest: Google has real documentation, a real API, and none of the guesswork involved in reading an undocumented endpoint.

It was not the easiest. It was the one that taught me the most.

No client secret, and what takes its place

Every OAuth tutorial I had seen before this one involved a client ID and a client secret. The ID identifies your app, the secret proves your app is really the one asking. So my first question was where to put the secret in an iOS app.

The answer is that you do not have one. A mobile app is what the spec calls a public client. Anything you ship is on someone else’s device, and anyone can pull strings out of an app binary in about a minute. A secret that everybody can read is not a secret, so Google does not issue one for iOS clients at all.

That leaves a hole. Without a secret, what stops an attacker who somehow intercepts your authorization code from redeeming it themselves?

That is the job PKCE does. The name stands for Proof Key for Code Exchange, and the idea is simpler than the name suggests:

  1. Before you send the user to Google, you generate a random string on the device. This is the verifier. Nobody has seen it.
  2. You hash it with SHA256. That hash is the challenge, and it is the only version Google sees at the start.
  3. Later, when you redeem the authorization code, you send the original verifier along with it.
  4. Google hashes what you sent and checks it against the challenge from step one. If they do not match, the code is refused.

So an intercepted code is worthless on its own. The thief would also need the verifier, which never left the device until the final request, and which is different every single time.

In Swift the whole thing is about twenty lines:

enum PKCE {
    static func makeVerifier() -> String {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        return Data(bytes).base64URLEncoded()
    }

    static func makeChallenge(from verifier: String) -> String {
        let hash = SHA256.hash(data: Data(verifier.utf8))
        return Data(hash).base64URLEncoded()
    }
}

The base64URL part matters. Regular base64 uses characters that mean something special inside a URL, so you swap the plus for a minus, the slash for an underscore, and drop the padding. Miss that and you get failures that only show up sometimes, which are the worst kind.

Step one: asking for permission

The first half is a browser trip. You build a URL, hand it to the system, the user signs in and approves, and Google sends you back to your app with a code.

On iOS the right tool is ASWebAuthenticationSession. Not a web view you build yourself. It runs in a separate process, so your app never sees the password, and it is the only approach Google still allows for sign in on mobile.

var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
components.queryItems = [
    URLQueryItem(name: "client_id", value: clientID),
    URLQueryItem(name: "redirect_uri", value: redirectURI),
    URLQueryItem(name: "response_type", value: "code"),
    URLQueryItem(name: "scope", value: scope),
    URLQueryItem(name: "code_challenge", value: challenge),
    URLQueryItem(name: "code_challenge_method", value: "S256")
]

Two things here took me longer than they should have.

The redirect URI is your client ID backwards. There is no web server to redirect to, so Google sends the user back using a custom URL scheme, and that scheme is your client ID with the parts reversed:

client ID:  123456789-abcdef.apps.googleusercontent.com
scheme:     com.googleusercontent.apps.123456789-abcdef
redirect:   com.googleusercontent.apps.123456789-abcdef:/oauth2callback

That scheme also has to be registered in Info.plist under CFBundleURLTypes, or iOS will not know the callback belongs to your app. One slash after the colon, not two. I copied that detail straight from Google’s docs and I am glad I did not try to reason it out.

The scope is the most important line in the request. Scope is what you are asking permission to do. I used drive.file, which grants access only to files my app itself creates. I could have asked for full Drive access and it would have worked just as well for reading a quota. Choosing the narrow one matters for a reason I did not appreciate until later in the week, and I will come back to it.

Step two: trading the code for tokens

The code you get back is not a credential. It is a claim ticket: single use, short lived, and useless on its own. You redeem it at a second endpoint, and this is where you finally send the verifier you generated at the very start.

var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

var form = URLComponents()
form.queryItems = [
    URLQueryItem(name: "client_id",     value: clientID),
    URLQueryItem(name: "code",          value: auth.code),
    URLQueryItem(name: "code_verifier", value: auth.verifier),
    URLQueryItem(name: "grant_type",    value: "authorization_code"),
    URLQueryItem(name: "redirect_uri",  value: redirectURI)
]
request.httpBody = form.percentEncodedQuery.map { Data($0.utf8) }

Three things in there are worth calling out, because each of them is a way to waste an afternoon.

It is form encoded, not JSON. OAuth is older than the assumption that every API speaks JSON, and the spec requires application/x-www-form-urlencoded. This is the only request in my entire app that is not JSON. Send JSON here and you get a 400 with a message that does not tell you why.

URLComponents builds the body, not the URL. This was a genuinely nice trick to learn. Set queryItems, then read back percentEncodedQuery, and you get a correctly escaped a=b&c=d string with no manual work. Authorization codes contain characters that must be escaped, and building that string by hand with interpolation is how you get bugs that appear for some users and not others.

redirect_uri is required even though nothing redirects. No browser is involved in this call at all. Google wants it anyway, so it can compare it against the one from the first request. It is a check that the two halves of the flow belong together.

One more habit worth building: check the status code before you decode. When the exchange fails, Google returns a completely different JSON shape, so decoding blind gives you a confusing keyNotFound error instead of the actual problem. Checking first, and printing the error body while you are still developing, turns a mystery into a sentence like invalid_grant or redirect_uri_mismatch.

Two tokens, two jobs

The exchange gives you back two very different things, and understanding why there are two was the moment OAuth stopped feeling arbitrary to me.

Token Lives for What it does
Access token About an hour Sent with every API request. A day pass.
Refresh token Until revoked Mints new access tokens. The key that makes day passes.

The split exists so the thing you put on the network constantly is the disposable one. If an access token leaks it is worthless by tomorrow. The valuable one stays in storage and is used rarely.

This created the first real design decision of the feature. My KeychainHelper stored exactly one string per account, because that is all OpenRouter and Claude ever needed: one API key, one session cookie. Google hands you three things: an access token, a refresh token, and an expiry.

I had three options.

  1. Store all three as a JSON blob in the single string slot. Fewer network calls, but now I am writing expiry logic and my “one secret” abstraction has quietly become “one encoded record”.
  2. Use suffixed keys, something like id-refresh and id-access. This one is a trap, and spotting why felt good. Removing an account deletes the key named after the account id, so the second key would survive deletion. A credential left behind on the device, and no test would ever catch it.
  3. Store only the refresh token, and mint a fresh access token on every fetch.

I went with the third. It costs one extra HTTP round trip per refresh, which nobody will ever notice, and in exchange there is no expiry bookkeeping anywhere in the app and KeychainHelper did not have to change at all. The simplest option that worked, and it kept an abstraction honest.

Wherever the token ends up, it goes in the Keychain and nowhere else. Not UserDefaults, not the JSON file the widget reads, not source code, and not a log line.

Reading the actual number

After all of that, the part I originally set out to build is one request.

GET https://www.googleapis.com/drive/v3/about?fields=storageQuota,user
Authorization: Bearer <access token>
{
  "storageQuota": { "limit": "16106127360", "usage": "8053063680" },
  "user": { "emailAddress": "me@gmail.com" }
}

Both numbers are bytes, sent as strings. Divide one by the other and you have the bar. That is the whole feature. Roughly one percent of the week went into this request and ninety nine percent went into being allowed to make it, which I suspect is normal and nobody mentions.

One detail worth handling: limit is absent for accounts with unlimited storage, so there is nothing to divide by. Returning an empty list beats crashing.

Where it fit

The part I am most pleased about is how little else had to change. Early on I had written a protocol that every provider implements:

protocol UsageService {
    var provider: Provider { get }
    func fetchUsage(for account: Account) async throws -> [UsageWindow]
}

The store that owns all the accounts holds a dictionary of [Provider: UsageService] and contains no switch on provider anywhere. So adding a fourth provider meant: write one new file, add one enum case, add one line to the services table, and add one method to the view model that matched the two already there.

Nothing in the store, the dashboard, the card view, the widget, or the persistence layer needed touching. I wrote that protocol months earlier because a tutorial told me to, and this was the week I actually understood why.

The detours that cost the most time

Almost none of my week went into OAuth. It went into these.

The simulator had no window. I kept being told the iOS Simulator was missing from my Xcode install. I deleted Xcode and reinstalled ten gigabytes over an hour, and the problem was identical afterwards. The actual answer: Xcode 27 removed Simulator.app and replaced it with Device Hub, which lives in a different folder that is not inside the path xcode-select -p reports. Nothing was broken. A lot of tooling is currently tripping over this same change. The lesson I took: when a fresh install reproduces a bug exactly, the assumption is wrong, not the install.

Passkeys and simulators do not mix. My Google account has a passkey, so Google went straight to passkey verification and offered me three ways to complete it: scan a QR code, use a security key, or use a passkey already on the device. A simulator has no camera, no NFC, and no passkey. All three doors were locked. There is a “Try another way” link that eventually gets you to a password. A real device would have been Face ID and two seconds.

An ellipsis in a code snippet. I was shown a snippet with the client ID abbreviated as "123456789-...apps.googleusercontent.com", and I pasted it exactly as written, because that is what you do with code. Google answered Error 401: invalid_client, which sent me looking at my Google Cloud configuration, which was fine all along. In prose an ellipsis means “you know the rest”. In code it is a bug. Now I read pasted config values character by character before I go blaming a service.

One account, forever. Once Drive worked I tried adding a second Google account and kept getting the first one back. ASWebAuthenticationSession shares cookies with Safari by default, so Google saw an existing session and never showed me the account picker. The fix is one line:

session.prefersEphemeralWebBrowserSession = true

Worth knowing that this is not only a testing annoyance. Without it, an app that supports multiple accounts per provider silently cannot have them. My Claude sign in has the same bug for the same reason, because a plain WKWebView uses a persistent shared cookie store, and the equivalent fix there is WKWebsiteDataStore.nonPersistent().

I pasted a refresh token where it did not belong. Debugging, I had the token printed to the console and I pasted the value into a chat window. Refresh tokens do not expire. Access tokens do. So the correct response to exposing one is to revoke it, not to wait it out, because you cannot know who read it.

The interesting part is how little damage was possible, and it was not luck. The scope was drive.file, which only reaches files my app created, and my app had created none. If I had asked for full Drive access out of convenience, the same careless paste would have exposed every file in my Drive. The scope you pick is the blast radius of a leak you have not had yet.

That also sent me back to delete the print statements that put the token on screen in the first place. On a real device those logs end up in diagnostic bundles. They were fine as a one time diagnostic and they had no business surviving into a commit.

What I would tell myself at the start

Read the flow end to end before writing any of it. OAuth is two requests with a browser trip in between. Once you can draw that on paper, every individual piece becomes obvious. I started coding after understanding request one, and paid for it.

Ask for the smallest scope that works. Check the API reference for which scopes a method accepts rather than reaching for full access. about.get accepts seven different scopes and the narrowest one was enough. Narrow scopes cost nothing and they cap the damage of a mistake you have not made yet.

Print the error body while developing, then delete the prints. Google’s failure responses are genuinely informative. Your own console output is not a safe place for a credential to live.

Test sign in on a real device. Simulators are excellent for layout, state, and logic. They are poor at anything involving the camera, biometrics, passkeys, or a keyboard behaving normally, and a sign in screen touches most of those.

When a fresh install reproduces the bug, your explanation is wrong. That one cost me an hour and ten gigabytes, and I expect it to save me more than that eventually.

The feature is a single bar on a card. It reports how full a Google Drive is. It took a week, and I understand public clients, PKCE, token lifetimes, Keychain design, and OAuth scopes in a way that no tutorial had managed to teach me. That trade was worth making.

Still on the list

  • The ephemeral session line, so a second Google account is actually possible
  • The same fix for Claude sign in
  • Reconnect currently shows the OpenRouter key screen for every provider, which makes no sense for an OAuth account
  • Showing the account email on the card instead of the word “Google Drive” twice, which means deciding whether a shared protocol should grow to carry one provider’s extra data

Frequently Asked Questions

Does an iOS app need a client secret for Google OAuth?

No. A mobile app is a public client, and anything inside the app binary can be read by anyone who has the app. Google doesn’t issue a client secret for iOS clients at all. PKCE takes over the job of proving the request really comes from your app.

What is PKCE and why does it matter on iOS?

PKCE stands for Proof Key for Code Exchange. The app makes a random verifier, sends only its SHA256 hash at the start, and sends the verifier itself when it redeems the code. An intercepted authorization code is useless without that verifier, which never leaves the device until the last request.

What redirect URI does Google use for an iOS app?

It’s your client ID with the parts reversed, used as a custom URL scheme, like com.googleusercontent.apps.123456789-abcdef:/oauth2callback. Note the single slash after the colon. The scheme also has to be registered under CFBundleURLTypes in Info.plist, or iOS won’t send the callback to your app.

Why does the Google token exchange return a 400 error?

The most common reason is sending JSON. The token endpoint expects application/x-www-form-urlencoded, and building the body with URLComponents and percentEncodedQuery gets the escaping right. A missing or different redirect_uri also fails, so check the status code and print the error body while you develop.

Why does ASWebAuthenticationSession always sign in the same Google account?

By default it shares cookies with Safari, so Google sees the existing session and skips the account picker. Setting prefersEphemeralWebBrowserSession = true gives every sign in a clean session. Without it, an app can’t hold two accounts from the same provider.