Recap
In the previous post, I used Gemini 3.8 Flash TTS to build Song Lingo: you paste a YouTube MV URL, Gemini transcribes the lyrics, adds furigana, translation, and grammar notes, and then a teacher designed via voice design reads it to you line by line.
It has only ever run on my own computer, but I want to be able to use it on my phone. So the goal of this post is simple: Move it to Cloud Run so it can be used on mobile.
However, this website has a unique characteristic: the page contains full lyrics and translations.
Requirement: How accurate does "Only I can see it" need to be?
When a typical side project is deployed, it's at most a bit embarrassing if others see it. Song Lingo is different; making it public has two practical consequences:
- Copyright: I spent a whole section in the last post explaining that this is a personal learning tool, and the lyrics only exist on my own computer. Once the site is public, the nature changes from "personal learning" to "providing lyrics to the public."
- Cost: Adding a song calls Gemini Flash, and playing the demo audio for the first time calls TTS. If anyone can use it, it means anyone can spend my quota.
So the goal isn't just to "have a login function," but to ensure at every layer, from start to finish, that only my account can access the lyrics and APIs.
I first compared two solutions:
| A. IAP + In-app Verification | B. No external access, use gcloud run services proxy
|
|
|---|---|---|
| Supported Devices | Any browser, including mobile | Only computers logged into gcloud
|
| Setup Difficulty | Medium | Low |
| Potential for Error | Low | Lowest, no public entry point at all |
Option B is the safest, but it doesn't work on mobile, which is the whole reason for this deployment. So I chose A.
Vulnerability in Version 1: Signed URLs
My initial plan was this: store audio files in GCS, and when playing, the API generates a short-lived signed URL and redirects the browser to it. The advantage is that the audio doesn't pass through Cloud Run, saving bandwidth, and GCS itself supports Range requests.
Halfway through writing, while reconsidering "how to accurately ensure only I can see it," I realized this was a vulnerability:
Within the expiration period, anyone who gets the signed URL can download it directly, completely bypassing IAP.
It is essentially an anonymous bearer token. If the URL appears in browser history, is pasted somewhere, or is recorded by an extension, others can bypass all the previous login checks.
Cause & Solution: "Only I can access" is a chain; its strength depends on the weakest link. The final approach was not to use signed URLs. Audio files are always read by Cloud Run and then sent to the browser, and every playback must first pass IAP and in-app verification. An audio segment is about 250KB; the traffic cost of this extra hop is negligible.
Architecture
| Component | Approach |
|---|---|
| Container | One image containing both Node 22 and uv/Python; Next.js directly calls the original Python scripts |
| Song Data & Audio | Private Cloud Storage bucket, mounted as the /data folder
|
| API key | Secret Manager, provided as environment variables |
| Access Control | IAP + In-app verification of the IAP signature |
| Instances | Max 1, min 0; CPU always allocated |
Reasons for several decisions:
-
Mount the bucket as a folder instead of rewriting the code to call the GCS API. The program originally read/wrote to the
output/folder; after mounting, I just need to pointSONG_DATA_DIRto/data, requiring almost no code changes. - Run only 1 instance. To avoid duplicate audio generation, pausing calls when the quota is exhausted, and tracking the progress of adding new songs, these states are stored in memory. Also, the mounted bucket doesn't have cross-instance locking. For personal use, 1 is enough, and this ensures the mechanisms to prevent duplicate billing from the previous post remain effective.
-
CPU always allocated (
--no-cpu-throttling). "Adding a new song" continues transcription and analysis in the background after the response is sent. Cloud Run defaults to throttling the CPU after a response is sent, which would stall background tasks.
Four Layers of Protection
The final access control consists of four layers. If any single layer fails, the others still hold:
-
Cloud Run Permissions:
--no-allow-unauthenticated, only the IAP service account can call this service. -
IAP: Only accounts granted
roles/iap.httpsResourceAccessorcan pass, which is only me. - In-app Verification: Every request verifies the IAP assertion header and compares it against the email.
- Private Bucket: Public access is strictly prohibited; only the service account dedicated to this service can read/write.
The third layer might seem redundant since IAP is already in front. But it protects against "IAP layer misconfiguration": someone accidentally adding --allow-unauthenticated, IAP being turned off, or ingress settings being changed. If that happens, the third layer is the last line of defense.
Verifying IAP Signatures in Next.js 16
Next.js 16 renamed middleware to proxy.ts, which runs in the Node.js runtime by default, making it perfect for this:
export async function proxy(request: NextRequest) {
if (!process.env.K_SERVICE) return NextResponse.next();
const result = await checkIapAssertion(request.headers.get("x-goog-iap-jwt-assertion"), {
audience: process.env.IAP_AUDIENCE,
allowedEmails: process.env.ALLOWED_EMAILS,
});
if (!result.ok) {
console.warn(`[auth] rejected ${request.method} ${request.nextUrl.pathname}: ${result.reason}`);
return new NextResponse(result.reason, { status: result.status });
}
return NextResponse.next();
}
Two design highlights:
-
Use
K_SERVICEto determine if running on Cloud Run. This environment variable is automatically set by Cloud Run. It doesn't exist during local development, so local work is unaffected, and there's no need for an extra "disable verification" switch (which would eventually be accidentally pushed to production). -
Reject all if configuration is missing. If
IAP_AUDIENCEorALLOWED_EMAILSis not set, all requests return 500. A configuration error results in "the site won't open" rather than "the site is open to the public."
The verification itself follows IAP documentation: ES256 signature, issuer is https://cloud.google.com/iap, audience is /projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_ID, and the public key is fetched from Google's JWK endpoint.
How to Test Before Going Live
Since there's no real IAP to hit locally, I made the key source a replaceable parameter and tested 12 scenarios locally using my own generated ES256 keys:
| Scenario | Result |
|---|---|
| Allowed account (including case sensitivity) | Pass |
| Other accounts, no email | 403 |
| Wrong audience, wrong issuer, forged signature, expired, gibberish, missing header | 401 |
| Missing audience, empty allowlist | 500 |
Then I ran the production build in three modes:
| Mode | Result |
|---|---|
Local (no K_SERVICE) |
All 200 |
| On Cloud Run, but forgot settings | All 500 |
| On Cloud Run, settings complete, but missing or forged header | All 401 |
Pitfall 1: Another Claude window already did half the work
Halfway through coding, two files I didn't create appeared in git status: Dockerfile and .dockerignore.
It turns out I accidentally had two Claude Code windows open. Both were discussing song-lingo, and the other one had also talked about deployment and already written a Dockerfile using a different approach: mounting GCS as a folder, while I was currently writing a whole storage abstraction layer to change all reads/writes to GCS API calls.
After comparing, the mounting approach was clearly better: it required almost no code changes, and the only advantage of my abstraction (version checking on write) wasn't really needed for a single-instance, single-user scenario. So I deleted my abstraction and switched to mounting.
But that wasn't all. After deploying, I found the service was already on revision 2. Revision 1 had been created earlier that day, and IAP was already enabled. That window hadn't just written a Dockerfile; it had actually deployed once.
Cause & Solution: Two agents in the same repo and same GCP project were working independently, unaware of each other. Nothing went wrong this time because I checked git status before acting and checked the revision list and service settings after deploying, rather than assuming "I am the first." My habit going forward: Only do one task in one window, and always check what's already in the cloud before starting a deployment.
Pitfall 2: Personal Gmail projects, IAP returns 502 for every request
After deploying and enabling IAP, I opened the URL and got a 502.
Initially, I thought the program crashed, but a key clue was in the response headers:
HTTP/2 502
x-goog-iap-generated-response: true
Empty Google Account OAuth client ID(s)/secret(s).
x-goog-iap-generated-response: true means this error was returned by IAP itself; the request never reached my code. The message indicates IAP has no OAuth client to use.
The reason is that my project doesn't belong to any organization; it was created with a personal Gmail account. IAP defaults to using a Google-managed OAuth client, which only supports accounts within an organization. For such projects, you must create your own OAuth client:
- Google Auth Platform (OAuth consent screen): Set User Type to External.
-
Credentials → Create OAuth client ID → Web application, set the redirect URI to
https://iap.googleapis.com/v1/oauth/clientIds/CLIENT_ID:handleRedirect. - Use
gcloud iap settings setto apply the client ID and secret to this service.
I executed step 3 in my own terminal rather than in the Claude Code chat, so the client secret wouldn't appear in any chat history.
What if the consent screen is already public?
My project contains many other services, and the OAuth consent screen was already set to public for other apps. My first reaction was, "If it's public, can anyone log in? Should I switch back to testing mode?"
The answer is no need to change, and you shouldn't:
| Layer | Responsibility | Impact of being Public |
|---|---|---|
| OAuth Consent Screen | Confirm "Which Google account are you" | Any account can complete the login step |
| IAP Access Permissions | Confirm "Can this account use this service" | Unaffected |
| In-app Verification | Re-confirm signature and email | Unaffected |
The consent screen is only responsible for "identifying who you are." The actual decision of "whether you can enter" is made by the subsequent two layers. Switching back to testing mode would instead affect other services sharing the same consent screen: only test users could log in, and authorization would expire in about 7 days.
Cause & Solution: When you see a 502, check the response headers first. x-goog-iap-generated-response tells you directly if the problem is with IAP or your code. Personal Gmail projects need their own OAuth client; whether the consent screen is public does not affect who can use the service.
Pitfall 3: Ensuring .env and lyrics aren't uploaded
gcloud run deploy --source . uploads the entire folder to Cloud Build. The local .env has API keys, and output/ has all the lyrics; these must absolutely not be uploaded.
gcloud defaults to using .gitignore if .gcloudignore is missing, and both of these were already in .gitignore. But "should be excluded" wasn't enough, so I explicitly wrote a .gcloudignore and used gcloud's own command to list what would actually be uploaded:
gcloud meta list-files-for-upload .
The result was 48 files; .env, output/, node_modules, and .next were all 0.
Cause & Solution: For commands that send files out, use the tool's own listing feature to see "what is actually being sent" rather than relying on your own inference of ignore rules.
Small things that lead to misjudgment
-
zsh wildcards: Counting objects with
gcloud storage ls -r gs://bucket/**resulted in 0. zsh tries to expand**as a local wildcard first; if it finds nothing, it errors out, and the query is never sent. Adding quotes fixed it: 116 objects, perfectly matching local. -
Fields not found by
--formatdon't error: Querying bucket settings with--format='value(iamConfiguration.publicAccessPrevention)'gave empty output. The field name changed in newer gcloud versions, but it doesn't error; it just silently gives you a blank. Switching to JSON output revealedpublic_access_prevention: enforced.
The commonality here: "0" and "blank" do not mean "none" or "no problem." When verifying security settings, if you get an empty result, suspect the query itself first.
Verification: Every layer must be tested
The post-deployment verification checklist, every item was actually run:
| Check | Result |
|---|---|
| Open homepage, API, audio without logging in | IAP returns 302, redirects to Google login |
| Attach a forged IAP signature header | Still 302, IAP doesn't accept external signatures |
| Add a new song via POST without logging in | IAP returns 401 |
| Anonymous access to bucket files and listing | 403 |
| Cloud Run invocation permissions | Only IAP service account |
| Bucket public permissions | No allUsers or allAuthenticatedUsers
|
| My account logs in and plays | Normal, in-app verification 0 rejections |
| Other Google account logs in | "You don’t have access" |
The last two can only be tested in a browser. Confirming "my account can use it" is actually the most critical: the audience format was filled according to documentation; if it were wrong, I would pass IAP but be blocked by my own code. I confirmed this by searching Cloud Run logs for [auth] rejected, which returned 0.
I also saw something in the logs that made me very happy: I added a new song directly in the cloud, and the entire workflow—transcription, analysis, demo audio generation, and playing from the bucket—worked perfectly within the container.
Costs and To-dos
| Item | Estimate |
|---|---|
| Cloud Run | Scales to 0 when not in use, no charge; CPU always allocated only charges during instance uptime |
| Cloud Storage | ~27MB, less than US$0.01 per month |
| Cloud Build | A few minutes per deployment, within free tier |
| Gemini API | Same as local, billed by usage |
There are two more things I need to do in the Console:
- Restrict API key usage to only call the Generative Language API.
- Set budget alerts. If all the previous protections fail, this is the final insurance to cap losses and ensure I'm notified.
Also note: the bucket and local output/ are now two independent sets of data. Songs added in the cloud won't automatically sync back; use gcloud storage rsync when needed.
Key Takeaways
"Only I can access" is a chain, not a door. No matter how well IAP is set up, a single signed URL can bypass it. When checking access control, list all paths through which data can exit, not just the entrance.
When a setting is missing, the system should fail closed, not open. In-app verification rejects everything if settings are missing. If a future deployment misses an environment variable, the result will be that I can't open the site and will notice immediately, rather than the site silently being open to the public for weeks.
Look at who returned the error. For a 502, the x-goog-iap-generated-response header narrows the problem down from "the whole service" to "IAP OAuth configuration."
Suspect the query first for empty results. An object count of 0 or a blank configuration field might be a malformed query rather than the resource not existing. In security verification, the cost of such a misjudgment is particularly high.
Do one task in only one agent window. Two Claude Code windows acting on the same project simultaneously were only prevented from overwriting each other because I checked the current state before acting.
Keep secrets out of the conversation. OAuth client secrets and API keys were handled in my own terminal. The AI was only responsible for giving me the commands, ensuring secrets never entered the chat history.
The code is at kkdai/song-lingo. The deployment section of the README has complete commands and a verification checklist (values replaced with placeholders). Related documentation: IAP for Cloud Run, Verifying IAP assertion headers, IAP custom OAuth configuration.
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support