Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse
- Part 1: How fzf Just Became Way More Memory-Efficient: The Bitmap Cache Upgrade
- Part 2: Unlocking Faster Fuzzy Finding: How a Smart Work Queue Made fzf Even Quicker
- Part 3: Speeding Up fzf: How a Tiny Change Made the World's Fastest Fuzzy Finder Even Faster
- Part 4: Fixing a Sneaky XSS Vulnerability in Hugo: Inside the Commit That Makes Markdown Rendering Safer
- Part 5: Fixing Syncthing’s REST API: Why “application/json; charset=utf-8” Was Wrong and How a Simple Commit Made It Right
- Part 6: Fixing UI Lag | Lazygit just got a major speed boost for one of its most frustrating pain points
- Part 7: Traefik PR #12880: A Tiny Fix That Squashes Unnecessary Allocations and Kills Routing Lag
- Part 8: Fixing Header Sanitization in Traefik’s PassTLSClientCert Middleware: A Deep Dive into PR #12875
- Part 9: Fixing URL Prefix Stripping in Traefik: Inside Pull Request #12863
- Part 10: Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse
- Part 11: Fixing a Sneaky Security Bug in Gitea: Users Could No Longer Change Someone Else’s Primary Email
TL;DR (for skimmers) 🔗
- Problem: Gitea’s OAuth2 “authorization codes” never expired and could be reused in race conditions → potential security risk.
- Fix: Two tiny, targeted pull requests now enforce proper expiration + single-use rules.
- Who did it: @lunny (main PR) + GiteaBot (backport).
- Status: Merged into Gitea v1.26 and backported to v1.25.5.
- Result: Full compliance with OAuth2 RFC 6749, no more token leakage from reused/expired codes.
Quick OAuth2 Refresher (30 seconds) 🔗
OAuth2 Authorization Code Flow is the most common way apps let you “Sign in with Gitea”.
- User clicks “Sign in with Gitea” → Gitea shows login/consent screen.
- Gitea gives the app a short-lived authorization code.
- The app exchanges that code once for an access token.
Key security rules (from RFC 6749):
- The code must expire quickly (usually minutes).
- The code can be used only once.
- If anything goes wrong → return
invalid_grant.
Gitea followed most of the spec… but skipped the expiry and reuse checks. Until now.
Classic OAuth2 Authorization Code Flow (visual reference)
The Bug (Before the Fix) 🔗
- Gitea generated authorization codes but never set an expiration timestamp (
ValidUntilfield existed but was ignored). - Once a code was used, Gitea called
Invalidate()but didn’t check if it had already been invalidated. - Result in concurrent requests (e.g., two tabs, retry logic, or network glitches): the same code could be exchanged twice, potentially leaking tokens or creating inconsistent state.
This wasn’t a theoretical edge case — it violated the spec and opened a small but real attack surface.
The Fix: What Changed (Super Simple Breakdown) 🔗
PR #36797 (Main Fix – March 2026) by @lunny 🔗
Link: github.com/go-gitea/gitea/pull/36797
Three precise changes (only ~50 lines of code):
models/auth/oauth2.go
- When creating a code → now sets
ValidUntil = now + lifetime. - Added helper
IsExpired()that simply checks the timestamp. - Improved
Invalidate()to detect “already invalidated” (double-use) and return a clear error.
- When creating a code → now sets
routers/web/auth/oauth2_provider.go
- During code → token exchange:
- First check
IsExpired()→ if yes, returninvalid_grant. - If double-invalidation detected, map it to a clean OAuth error (no token leakage).
- First check
- During code → token exchange:
models/auth/oauth2_test.go
- New tests cover:
- Correct timestamp on creation
- Expired codes are rejected
- Concurrent double-use is blocked
- New tests cover:
Before vs After (pseudocode):
// BEFORE (broken)
func (c *AuthorizationCode) Invalidate() {
// just mark it invalid
}
// AFTER (fixed)
func (c *AuthorizationCode) IsExpired() bool {
return time.Now().After(c.ValidUntil)
}
func (c *AuthorizationCode) Invalidate() error {
if c.alreadyInvalidated {
return ErrDoubleInvalidation // ← new protection
}
// mark invalid
}
PR #36851 (Backport for v1.25 users) 🔗
Link: github.com/go-gitea/gitea/pull/36851
Automated backport by GiteaBot so everyone on 1.25.x also gets the fix (merged into v1.25.5).
Why This Matters (The “Why” Behind the Commits) 🔗
- Security: Prevents replay attacks and race-condition token leaks.
- Standards compliance: Now matches OAuth2 spec exactly.
- Reliability: Apps that retry token exchange (very common) no longer get weird errors.
- Maintainability: Clear error messages + tests make future debugging easy.
The commit messages and PR description are crystal clear: “The OAuth2 code expiration time has never been used.” — Lunny was fixing something that was always broken, not just a recent regression.
How the New Flow Works (Step-by-Step) 🔗
- App requests authorization → Gitea creates code with
ValidUntil. - User consents → code returned.
- App calls
/login/oauth/access_tokenwith the code.- Gitea checks
IsExpired()→ rejects if too old. - Gitea calls
Invalidate()→ if already used, blocks it.
- Gitea checks
- Success → fresh access token (single use guaranteed).
Goal achieved: Every authorization code now has a strict lifetime and is strictly single-use.
Who Should Care? 🔗
- Self-hosted Gitea admins → update to 1.26 or 1.25.5+
- App developers using Gitea as OAuth provider → your token-exchange code is now safer
- Contributors → see how a 3-file, 50-line PR can close a real security gap
Final Word 🔗
These two pull requests are perfect examples of quiet, high-impact fixes that make open-source software more secure without changing the user experience at all.
Thanks to @lunny for the clean implementation and the Gitea team for the rapid backport.
Links
- Main fix: PR #36797
- Backport: PR #36851
- Full OAuth2 spec (for nerds): RFC 6749 §4.1
Happy (and secure) self-hosting! 🚀
I hope you enjoyed reading this post as much as I enjoyed writing it. If you know a person who can benefit from this information, send them a link of this post. If you want to get notified about new posts, follow me on YouTube , Twitter (x) , LinkedIn , and GitHub .
- Part 1: How fzf Just Became Way More Memory-Efficient: The Bitmap Cache Upgrade
- Part 2: Unlocking Faster Fuzzy Finding: How a Smart Work Queue Made fzf Even Quicker
- Part 3: Speeding Up fzf: How a Tiny Change Made the World's Fastest Fuzzy Finder Even Faster
- Part 4: Fixing a Sneaky XSS Vulnerability in Hugo: Inside the Commit That Makes Markdown Rendering Safer
- Part 5: Fixing Syncthing’s REST API: Why “application/json; charset=utf-8” Was Wrong and How a Simple Commit Made It Right
- Part 6: Fixing UI Lag | Lazygit just got a major speed boost for one of its most frustrating pain points
- Part 7: Traefik PR #12880: A Tiny Fix That Squashes Unnecessary Allocations and Kills Routing Lag
- Part 8: Fixing Header Sanitization in Traefik’s PassTLSClientCert Middleware: A Deep Dive into PR #12875
- Part 9: Fixing URL Prefix Stripping in Traefik: Inside Pull Request #12863
- Part 10: Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse
- Part 11: Fixing a Sneaky Security Bug in Gitea: Users Could No Longer Change Someone Else’s Primary Email