DEV Community

Cover image for My "one-time token" handed out the real API key in two requests
Enlh NG
Enlh NG

Posted on Edited on

My "one-time token" handed out the real API key in two requests

I run a free speech-to-text page that calls a paid transcription API. The API key must never reach the browser, so I added what I thought was a careful design: the page asks my server for a temporary token, the token lasts 30 seconds, and it works only once.

Then I wrote a test for it, and the test showed that anyone could get the real key in two requests. This post shows the old code, the measurements, the proxy that replaced it, and the ten tests I use now. All keys in the examples are fake.

The design that felt safe

This is the old code, shortened:

_stt_tokens = {}            # { token: {"key": "gsk_...", "expires": ts} }

@app.get("/api/stt-token")
async def get_stt_token():
    """Token holds the real API key and expires after 30 seconds.
    The key never appears in the HTML/JS source."""
    token = secrets.token_urlsafe(32)
    _stt_tokens[token] = {"key": next_key(), "expires": time.time() + 30}
    return {"token": token, "expires_in": 30,
            "groq_url": "https://api.groq.com/openai/v1/audio/transcriptions",
            "model": "whisper-large-v3-turbo"}

@app.post("/api/stt-token/validate")
async def validate(token: str = Form(...)):
    entry = _stt_tokens.pop(token, None)      # single use
    if not entry or time.time() > entry["expires"]:
        raise HTTPException(401, "Invalid or expired token")
    return {"key": entry["key"]}              # <- the real key
Enter fullscreen mode Exit fullscreen mode

The page would fetch a token, exchange it for the key, and call the transcription API straight from the browser. The docstring I wrote even argued why this was safe: the key is not in the source, the token is random, it expires, it is single-use, and I can rate-limit the token endpoint.

Every one of those statements was true. None of them protected the key.

The two requests

I rebuilt just those two endpoints in a test app with a fake key and called them as any visitor could:

1) GET  /api/stt-token          -> 200 ['token', 'expires_in', 'groq_url', 'model']
2) POST /api/stt-token/validate -> 200 {'key': 'gsk_FAKE_KEY_FOR_DEMO_0000...'}
   same token again             -> 401 {'detail': 'Invalid or expired token'}
3) 1000 token->key exchanges in 3.13s, no rate limit, distinct keys obtained: 1
Enter fullscreen mode Exit fullscreen mode

The token is single-use, as promised. But the thing it unlocks is the key itself, and nothing stops a visitor from asking for a new token. A thousand exchanges took 3.1 seconds in my sandbox, and I had not written the rate limit the docstring said I could add. Two curl calls are enough, and you do not even need the page.

What the token actually protected

What I wrote What it protects against What the attacker wanted
Key is not in the source Someone grepping my HTML The key
Token is random Guessing a token The key
Expires in 30 seconds Re-using an old token The key
Single use Replaying a token The key

Four controls on the token, none on the key

All four controls guard the token. The asset was the key, and the design had a door that exchanged tokens for it. If the browser is going to use the key, then the visitor has the key. A short lifetime only helps when what you hand out is itself a short-lived credential (for example a signed upload URL) and not the long-lived secret.

Any key that was ever served by such an endpoint has to be treated as leaked, whatever the token lifetime was. Rotate it.

The fix: the browser never sees a key

Instead of giving the browser a credential, the server now does the call. The page posts the audio file to /api/stt on my own server, and the server forwards it to the transcription API with the key attached:

@app.post("/api/stt")
async def speech_to_text(request: Request, file: UploadFile = File(...),
                         language: str = Form(""), response_format: str = Form("json")):
    if not _stt_rate_ok(_stt_client_ip(request)):
        return _stt_error(429, "Rate limit reached. Please wait a minute and try again.")
    if os.path.splitext(file.filename or "")[1].lower() not in _STT_EXTS:
        return _stt_error(400, "Unsupported format. Please upload MP3, MP4, WAV, M4A, OGG, FLAC or WebM.")
    lang = language.strip().lower()
    if lang == "nb": lang = "no"
    if lang and not re.fullmatch(r"[a-z]{2,3}", lang):
        return _stt_error(400, "Invalid language.")
    ...
Enter fullscreen mode Exit fullscreen mode

Several details matter more than the happy path:

Validate before you spend anything. Rate limit first, then file extension, then the language code (a 2 or 3 letter string; the transcription API documents ISO-639-1 codes, and I map nb to no). Nothing is forwarded until all three pass.

Cap what you load into memory. The upload is read in 1 MB chunks, and the request is refused with 413 as soon as the running total passes 25 MB, because that is the limit of the free tier of the API I call. This bounds what my code holds in RAM. It does not stop the web framework from receiving and spooling a huge upload before my function runs, so the real size limit also belongs in the reverse proxy (in Nginx, client_max_body_size).

Limit concurrency. A semaphore allows two files in flight per worker, and a request that cannot get a slot within 20 seconds gets a 503. Without it, a burst of 25 MB uploads can exhaust memory, because each file is held in RAM.

Retry once on the next key. When the provider answers 429, the code retries once with the next key in a round-robin list. The test below checks that the second attempt really uses a different key.

Never forward upstream error text blindly. Only 400, 413 and 422 from the provider pass a message on, because those describe the user's file. A 401, a 5xx or a network error becomes a generic 502 ("service unavailable"), so a provider error that mentions a credential cannot reach the visitor.

The ten tests

I run these against a fake HTTP client, so no real API is called:

# Case Expected Result
1 Valid file, language=nb, verbose_json 200, upstream gets language=no, response has no gsk_ passed
2 .exe file 400 passed
3 Empty file 400 passed
4 Language xx1! 400 passed
5 25 MB + 1 byte 413 passed
6 Provider returns 429 once 200, second call uses another key passed (fake2, then fake1)
7 Provider returns 400 "file is not valid audio" 400 with that message passed
8 Provider returns 401 "Invalid API Key gsk_secret" 502, key text not in response passed
9 Network error 502 passed
10 Six requests from one IP 200 four times, then 429 passed ([200, 200, 200, 200, 429, 429])

After all ten, the semaphore had both slots free again, which is how I check that no code path leaks a slot.

Test 8 is the one I care about most. It is the test that would have caught a naive proxy that returns the provider's error body.

What is still imperfect

  • The rate limit is in memory and per worker. With two workers the real limit is about double the number in the code (4 a minute and 30 a day per IP, per worker). That is fine for a free tool, but it is not a precise quota.
  • The IP comes from X-Real-IP. That is only trustworthy if Nginx sets it and the app port is closed to the internet. Otherwise a client can invent a new IP on every request.
  • The whole file sits in memory while it is forwarded. The size cap and the semaphore bound it, but they do not remove it, and the size check in my function runs after the framework has already parsed the upload.
  • The audio does leave my server. It goes to a third-party API (Whisper large-v3-turbo, hosted by the provider). My code does not write it to disk, and the provider's docs page that I read does not state a retention period, so I cannot promise anything about what happens on their side.

Where the options differ (read Nov 2026)

If you only need a transcript, you may not want to build any of this. I tested my own code, not these sites; this is what each page states, and "not stated" means I did not find it on the page I read.

Option What it takes Limits Where the audio goes
Groq speech-to-text API (the engine behind my proxy) An API key 25 MB per file on the free tier, 100 MB on the dev tier; flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm Their servers; retention not stated
FastTranscriber A guest can upload without an account 2 uploads per day; TXT and SRT export; 98+ languages; file size not stated for the free tier; "20-minute transcript previews" "Private & Secure" badge; retention not stated
Speechnotes Dictation needs no account Dictation free; file transcription $0.1 per minute; dictation engines are "Google & Microsoft" Recordings "deleted from our servers as soon as the transcription is done"; dictation notes stay in the browser
Otter.ai free plan An account 300 minutes per month, 30 minutes per conversation, 3 lifetime file imports, six languages Not stated on the pricing page
Whisper Web Nothing to sign up for Not stated The page does not say whether the work happens on your device, so I do not claim it
ToolTiny (my page) No sign-up 25 MB, the formats above; 4 requests a minute and 30 a day per IP per worker File goes through my server to the provider's API; not written to disk by my code; provider retention not stated

The row I could not verify is Whisper Web. I used to describe it as running entirely on your device, but the page I read does not say that, so I am not repeating it.

Checklist before you proxy a paid API

  1. Never send a long-lived key to a browser, not even for 30 seconds. Hand out a credential only if it is itself short-lived and limited.
  2. Write the exploit as a test before you write the defence, and run it against the code you actually deploy.
  3. Validate cheap things first (rate, type, language), then read the body with a size cap, then spend money.
  4. Bound concurrency and memory, and test that a slot is released on every path.
  5. Map provider errors to generic ones unless the message is about the user's input.
  6. Rotate any key that was ever exposed.

Limits of this post

  • The old endpoints were rebuilt from my own code in a test app with a fake key, and nothing contacted a real service. The numbers (a thousand exchanges in 3.1 seconds) come from my sandbox, not from a production server.
  • The ten tests use a fake HTTP client. They show how my code handles each response, not how the real provider behaves.
  • I did not measure transcription accuracy or speed in this post.
  • Memory use of the proxy was not measured. I only describe how it is bounded.
  • Vendor statements are as they appeared on the day I read them.

If you have found a safe way to let a browser call a paid API directly, I would like to hear what you hand out. Tell me in the comments.

Top comments (0)