OAuth is the wrong shape for a contact form
The job is small. Someone fills in a form, and one email goes from our server to our own inbox. This repository has built that three times:
- 2017: Amazon SES, through Python's own
smtplib. A username, a password, and about twenty lines of code. - 2024: the Gmail API, through Google's client libraries and OAuth.
- 2026: Resend, through one HTTP POST from Tornado's own HTTP client.
The first and last have the same shape: one credential and one protocol call. This note is about the middle one, what it cost, and what turned up when we went back to look at it.
What the Gmail version needed
requirements.txt grew by three lines in June 2024. Those three lines
install twenty-three packages.
Then there is everything around the packages. A Google Cloud project. An
OAuth consent screen. A client ID and a client secret. A refresh token,
generated once by a person clicking through a browser login, then pasted
into the server's environment (support for that arrived in October 2025;
before it, the code read a token.json off disk). If the consent screen
is left in Testing status, as a personal project's usually is, Google
expires that refresh token after seven days.
And there are the scopes the code asked for:
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.send"]
Sending needs only the last one. The first grants read access to every message in the mailbox. A contact form, a public endpoint that anyone on the internet can POST to, was holding a credential that could read the owner's whole inbox.
The fallback path is stranger. With no token in the environment, the
handler ran InstalledAppFlow.from_client_secrets_file(...) and then
flow.run_local_server(port=0). That is Google's flow for desktop apps:
it opens a browser on the local machine and waits for a person to log in.
On a server, inside a request handler, there is no browser and no person.
None of this is a bug in Google's libraries. They do what OAuth is for: letting one party act on another party's account, with that party's consent, revocably. A contact form has no other party. The person who owns the inbox wrote the server. There is nobody to ask for consent, so all that is left of OAuth is its cost.
The form never reached it
Writing this note meant checking what the Gmail code actually did. The answer, on master, is nothing at all.
On 2021-02-17 a commit titled zombie commit, turn on xsrf_cookies set
xsrf_cookies=True. With that setting on, Tornado rejects any POST that
does not carry an _xsrf token, and it does so before the handler runs.
The form template never included {% module xsrf_form_html() %}, so the
form had no token to send. From that commit on, every browser submission
got 403: Forbidden.
The Gmail commit was written in June 2024 on a checkout whose newest commit was from August 2017, from before XSRF was turned on. On that commit the form works. Eighty-nine seconds later, the merge that brought it up to date with master turned XSRF back on, and the Gmail code joined the SES code behind the 403.
The checks behind the chart, run on current Tornado (6.5.5) with the mail function replaced by a counter:
| commit | branch | date | XSRF on | token in form | browser submit | mail code ran |
|---|---|---|---|---|---|---|
909dba9 |
master | 2017-08-30 | no | no | 200 | yes |
21232e1 |
master | 2021-02-17 | yes | no | 403 | no |
757fae4 |
svelte | 2023-01-02 14:39 | yes | no | 403 | no |
29af1cb |
svelte | 2023-01-02 14:56 | yes | yes | 200 | yes |
20879ed |
svelte | 2023-01-02 16:49 | yes | no form | no route | no |
253b580 |
(side) | 2024-06-08 14:47 | no | no | 200 | yes |
2477f30 |
master | 2024-06-08 14:49 | yes | no | 403 | no |
be4ab86 |
master | 2025-10-23 | yes | no | 403 | no |
(The commits from 2021 and 2023 only import on modern Tornado with a shim
for the removed @tornado.web.asynchronous decorator, which another
handler used and which has nothing to do with the XSRF check.)
So for 1,806 days, from February 2021 to the Svelte merge, master had a contact form that no browser could use. The Gmail integration, with its twenty-three packages and its inbox-wide scope, spent its whole life behind that 403. The environment-variable support added in October 2025 was careful work on code that could not be reached.
Someone did fix it once. On the svelte branch's first afternoon, a commit
called more generic landing page copy courtesy of chatgpt added the
missing token to the template at 14:56, and the form worked. At 16:49 the
branch switched to serving the Svelte app, which had no route for the
form, and a week later it deleted the mail code. The token never made it
back to master.
Git cannot say which commit the server was running in those years. It can say that between February 2021 and July 2026, no commit on either line of history could deliver a browser's message, apart from two: one for an hour and fifty-three minutes, and one for eighty-nine seconds.
The failure was loud for exactly one person, the visitor, who got a bare 403 page and had no way to report it. It was silent for the person who could have fixed it, because a message that never arrives looks the same as nobody writing. It belongs with the five failures that were all repaired on one day in July 2026: each of them was loud somewhere nobody was looking.
The Resend version
The current form is handlers/contact.py, 170 lines with its guards, and
the part that sends is one request:
request = HTTPRequest(
RESEND_ENDPOINT,
method='POST',
headers={
'Authorization': 'Bearer %s' % self.api_key,
'Content-Type': 'application/json',
},
body=json.dumps(payload),
request_timeout=15,
)
await AsyncHTTPClient().fetch(request)
No SDK, and no new package: AsyncHTTPClient is part of Tornado, which
the site already runs on. The key is created in Resend's dashboard and can
be restricted to sending, and to one domain, which is the least privilege
the Gmail scopes never had. The visitor's address goes in reply_to, so
answering them is just hitting Reply. The form never needs to read mail
to make that work.
XSRF is on, and this time it is wired through. SpaStaticFileHandler
touches self.xsrf_token when it serves the app shell, which sets the
_xsrf cookie. Contact.svelte reads the cookie and sends it back in an
X-XSRFToken header. Two tests hold that in place:
test_valid_submission_sends gets the cookie the way a page load would
and expects a send, and test_xsrf_required leaves it off and expects a
403. The old form had the setting and no test, which is how it spent
nearly five years rejecting everyone.
Guards that say what they can't do
The guards are ordinary: required fields, length caps (a 10,000-character
message, a 320-character address), a hidden website field as a
honeypot, and a limit of five sends per hour per IP. The honeypot answers
200, so a bot gets no signal to adapt to.
The part worth copying is the limiter's docstring:
class RateLimiter:
"""
In-memory and per-process, so it resets on restart and doesn't survive
multiple workers. That is fine for the volume this form will see; it
exists to blunt a script, not to be an authority.
"""
That is a limitation written down at the point where someone would otherwise discover it. It names the condition that breaks it (more than one worker) and the job it is actually doing (slowing a script, not enforcing a quota). The same caveat is in this repo's agent instructions, so anyone who scales the site out reads it before they find out the hard way. Every guard has a limit, and the useful ones say so.
When OAuth is the right shape
When the account belongs to somebody else. If an app reads a customer's calendar, or sends mail as the customer, from the customer's own address, then delegation is the whole point, and the consent screen is part of the product.
When the account is yours and so is the server, use the thing built for servers: a transactional email API with a key, or SMTP with a password, which is what the 2017 version did in twenty lines. The test is whether there is anyone to delegate to. If the answer is you, OAuth adds a browser login, a token lifecycle, and a library stack to a job that needs one secret and one request.