OAuth2 from scratch in Python: what the tutorials don't cover
Every OAuth2 tutorial shows you the happy path. The user clicks a button, gets redirected, grants permission, comes back with a code, and the code becomes a token. The tutorial ends there.
I implemented OAuth2 twice in three months — once for ontap.systems, integrating with GitHub's app flow, and once for Fanella, building a client credentials server from scratch. Here is what the second implementation knew that the first one didn't.
Token storage
The first thing to settle is where the refresh token lives. For ontap, refresh tokens are stored in a RefreshToken table with a one-to-one relationship to the user and a unique token_id field. On each OAuth callback, the old token record is deleted and a new one is created.
createNewToken = token.refresh_token
db(
RefreshToken.user == auth.user
).delete()
RefreshToken.create(token_id=createNewToken, user=auth.user)
A refresh token is a single-use credential. Storing it without rotation is a standing vulnerability.
Token rotation mid-request
When a request needs the user's access token and the token has expired, the sequence is: read the refresh token ID from the database, call the provider's refresh endpoint, get a new access token and a new refresh token, update the database with the new refresh token ID, then proceed with the new access token.
if auth.user.refresh_token():
r_token = auth.user.refresh_token().token_id
token = git_app.refresh_access_token(r_token)
createNew = token.refresh_token
db(RefreshToken.token_id == r_token).update(token_id=createNew)
user_auth = git_app.get_app_user_auth(token)
g = Github(auth=user_auth)
repos = g.get_user().get_repos()
The failure mode is refreshing the token but not updating the database. The next request tries to use the old refresh token, the provider rejects it, and the user's session is broken. Update the database before you use the new token, not after.
Secret generation
For Fanella's client credentials flow, each client gets a client_id and a client_secret generated on creation.
client_id: Mapped[str] = mapped_column(
default=lambda: str(uuid.uuid4())
)
client_secret: Mapped[str] = mapped_column(
default=lambda: secrets.token_hex(32)
)
The client_id is str(uuid.uuid4()). The client_secret is secrets.token_hex(32) — a 64-character hex string from Python's cryptographically secure random number generator. These are not interchangeable. UUID4 is random but not designed for use as a secret. secrets.token_hex gives you 256 bits of cryptographic randomness. For anything that functions as a password, use secrets.
The simultaneous match
When validating client credentials, query for the record matching both the client_id and the client_secret in a single query.
client = await crud.client_credentials.get(
db,
and_eq_client_id=obj_in.client_id,
and_eq_client_secret=obj_in.client_secret
)
if not client:
raise errors.invalid_credentials
Querying by client_id first and then checking the secret creates a timing oracle — an attacker who can measure response times can infer whether a client ID is valid. Match both in one query and fail the same way regardless of which field is wrong.