All Posts programming Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse

Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse

· 732 words · 4 minute read
Go in production: interesting moments ▹

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”.

  1. User clicks “Sign in with Gitea” → Gitea shows login/consent screen.
  2. Gitea gives the app a short-lived authorization code.
  3. 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 Classic OAuth2 Authorization Code Flow (visual reference)


The Bug (Before the Fix) 🔗

  • Gitea generated authorization codes but never set an expiration timestamp (ValidUntil field 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):

  1. 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.
  2. routers/web/auth/oauth2_provider.go

    • During code → token exchange:
      • First check IsExpired() → if yes, return invalid_grant.
      • If double-invalidation detected, map it to a clean OAuth error (no token leakage).
  3. models/auth/oauth2_test.go

    • New tests cover:
      • Correct timestamp on creation
      • Expired codes are rejected
      • Concurrent double-use is blocked

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) 🔗

  1. App requests authorization → Gitea creates code with ValidUntil.
  2. User consents → code returned.
  3. App calls /login/oauth/access_token with the code.
    • Gitea checks IsExpired() → rejects if too old.
    • Gitea calls Invalidate() → if already used, blocks it.
  4. 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

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 .

Go in production: interesting moments ▹