The simpler a problem looks, the easier it is to get stuck on the details.
Why this came up
I recently needed a Python script on Windows to support something like a third-party sign-in flow. In practice, that usually means OAuth 2.0. The platform I was integrating with used the same approach, and the key step was getting the authorization code returned from the browser so it could be exchanged for user information.
At first this sounded like a small implementation detail. In reality, choosing how to receive that code turned out to be the tricky part.
Option 1: Register a custom URI scheme
One approach is the kind of thing apps like VSCode seem to use: register a custom protocol with the operating system, then have the OAuth callback invoke your program directly.
On Windows, that means adding a pseudo-protocol to the registry:
Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\mayx]
"URL Protocol"="D:\\mayx.exe"
@="MayxProtocol"
[HKEY_CLASSES_ROOT\mayx\DefaultIcon]
@="D:\\mayx.exe,1"
[HKEY_CLASSES_ROOT\mayx\shell]
[HKEY_CLASSES_ROOT\mayx\shell\open]
[HKEY_CLASSES_ROOT\mayx\shell\open\command]
@="\"D:\\mayx.exe\" \"%1\""
Then a tiny Python script can read the callback URL from the command line after being packaged with PyInstaller:
import sys
print(sys.argv[1])
If the OAuth callback URL is set to mayx://get, a successful authorization will launch the program like this:
D:\mayx.exe mayx://get?code=something
From there, parsing the URL with urllib is straightforward.
At a glance, this feels wonderfully simple. In practice, it comes with some nasty edges.
The first problem is Windows security software. A registry entry like shell\open\command is exactly the kind of thing antivirus tools tend to treat suspiciously, because malware has abused it so often. Unless the software has a digital signature or some other trust signal, importing that registry configuration may be blocked outright.
The second issue is compatibility: not every third-party platform allows callbacks to custom URI schemes. The one I was using actually did support it, so that part was fine. Still, if security software is going to fight the install process, the elegance starts to wear off pretty quickly.
Option 2: Listen locally over HTTP
A lot of cross-platform applications—especially tools more at home on Linux—solve this by spinning up a local web server temporarily and receiving the OAuth callback over http://127.0.0.1. In the end, this route felt more practical and less likely to fail for strange system-level reasons.
Trying Flask first
My first thought was to just use Flask. It only takes a few lines:
from flask import Flask, request
code = ""
app = Flask(__name__)
@app.route('/getcode')
def get():
global code
code = request.args.get("code")
shutdown = request.environ["werkzeug.server.shutdown"]
shutdown()
return "OK"
app.run(host="127.0.0.1",port=8000)
print(code)
This worked nicely and did exactly what it needed to do. But it also felt like overkill. Using Flask for a one-shot local callback handler is a lot of framework for a very small job, and the packaged executable ends up larger than it needs to be.
On top of that, the shutdown approach involved here has already been deprecated, which made the whole solution feel a bit unsatisfying even though it worked.
Falling back to raw sockets
That made me think: if a full web framework feels too heavy, why not just do it directly with socket?
I had previously built a simple forum project with sockets, so a tiny callback listener sounded manageable. I came up with this version:
import socket
import urllib.parse
server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) #打开一个网络连接
server.bind(('127.0.0.1',8000)) #绑定要监听的端口
server.listen(5) # 设置最大的连接数量为5
code = ""
while True:
sock, addr = server.accept() # 建立客户端连接
data = sock.recv(8192).decode('utf-8').split('\r\n')#接收TCP数据,数据以字符串的形式返还
if not data[0]:
sock.close() # 关闭连接
continue
url = urllib.parse.urlparse(data[0].split()[1])
if url.path == '/getcode':
query = urllib.parse.parse_qs(self.data)
code = query["code"][0]
sock.send(("HTTP/1.0 200 OK" + '\r\n').encode('utf-8'))
sock.send(("Content-Type: text/html; charset=utf-8" + '\r\n').encode('utf-8'))
sock.send('\r\n'.encode('utf-8'))
sock.send("OK".encode('utf-8')) #发送TCP数据
sock.close() # 关闭连接
break
else:
sock.send(("HTTP/1.0 404 Not Found" + '\r\n').encode('utf-8'))
sock.send(("Content-Type: text/html; charset=utf-8" + '\r\n').encode('utf-8'))
sock.send('\r\n'.encode('utf-8'))
sock.send("Not Found".encode('utf-8')) #发送TCP数据
sock.close() # 关闭连接
print(code)
It definitely looked more complicated than the Flask version, and although it seemed to behave normally most of the time, there was an annoying issue: the first request would sometimes just hang for no obvious reason. After spending far too long trying to debug it, I gave up on this path.
That was the point where “simple” had clearly become more trouble than it was worth.
Using Python’s built-in http.server
Then I remembered http.server, the standard-library module I usually use for quick file sharing. Since it ships with Python, it should keep dependencies and package size under control.
So I rewrote the callback listener around that:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import urllib.parse
code = ""
class Resquest(BaseHTTPRequestHandler):
timeout = 5
def do_GET(self):
url = urllib.parse.urlparse(self.path)
if url.path == "/getuser":
self.send_response(200)
self.send_header("Content-type", "text/html") # 设置服务器响应头
code = urllib.parse.parse_qs(url.query)["code"][0]
buf = '''OK'''
self.wfile.write(buf.encode())
self.server.shutdown()
else:
self.send_response(404)
self.send_header("Content-type", "text/html") # 设置服务器响应头
self.end_headers()
buf = '''Not Found'''
self.wfile.write(buf.encode())
host = ("127.0.0.1", 8000)
server = ThreadingHTTPServer(host, Resquest)
print("Starting server, listen at: %s:%s" % host)
server.serve_forever()
server.socket.close()
print(code)
After packaging and testing it, this version worked properly. The executable was still not tiny, but it was lighter than the Flask-based build, which already made it feel like the better compromise.
There was also one detail that took a while to figure out: at first I hadn’t added server.socket.close() at the end. Without it, the port would reliably remain occupied until the process fully exited, which was extremely annoying. Searching for an answer didn’t turn up anything especially helpful, but asking ChatGPT solved it immediately.
The explanation was that calling server.shutdown() stops the server from accepting new connections and closes existing ones, but the underlying socket does not necessarily release the port instantly. Instead, it can remain in the TCP TIME_WAIT state for a while so the network stack can safely handle any in-flight data. Once that clicked, the behavior made much more sense.
AI really is convenient sometimes.
Where I landed
For this kind of OAuth callback handling in a Windows Python script, the built-in HTTP server ended up being the most comfortable balance.
A custom URI scheme looks elegant, but system security policies can get in the way. Flask is easy, but heavy for such a narrow task. Raw sockets sound minimal, yet can become surprisingly annoying to debug. http.server, on the other hand, turned out to be simple enough, built-in, and good enough for the job.
It is a little funny how such a small problem can take so long to settle on the “right” solution. Maybe that is the downside of knowing too many possible approaches: the simplest answer is not always the first one you try.