Delegated logins: OAuth basics

Many web applications offer authentication through an external provider, and GitHub is a common choice for developer-facing services. OAuth 2.0 is the underlying standard, and while the flow described here focuses on authentication (authn) — "does this user have a valid GitHub account?" — the protocol itself is designed for authorization (authz). When you initiate a GitHub OAuth login, you're actually requesting specific permissions, or "scopes," on a user's account. The examples in this post deliberately request no scopes beyond the basic user information (such as email) needed to identify the account to your application.

This article covers three ways to implement "Sign in with GitHub" in a Go service:

  1. Using the Go standard library alone
  2. Using golang.org/x/oauth2 to handle OAuth details
  3. Using the gologin package to encapsulate nearly the entire process

The actors in an OAuth exchange

OAuth 2.0, formally described in RFC 6750, defines a role for each party involved:

  • Application: your web app that offers GitHub-based login.
  • User: someone with a GitHub account logging into your app.
  • User-agent: the user's browser, which facilitates redirection between servers.
  • Auth Server: GitHub's OAuth authorization server.
Diagram describing the steps and actors in an OAuth 2 auth flow

The flow itself is a series of HTTP redirects:

  1. The user opts to log in via GitHub; your app redirects them to GitHub's auth server.
  2. GitHub authenticates the user on its own site, in a secure session.
  3. On success, GitHub redirects the browser back to your app with a temporary code.
  4. Your app exchanges that code, plus its pre-registered client secret, for an access token at GitHub's token endpoint.
  5. GitHub returns the access token, which your app can then use against the GitHub API.

Redirection via HTTP status code 301 is what makes this scheme work — it carries the user between the application and auth server, and it also means a callback URL can point at localhost during development. The redirection URL is passed as a request parameter when the user is sent to GitHub, and GitHub uses it to return the user to the right place in step 3.

Sample 1: A bare-metal stdlib implementation

The first approach follows GitHub's documented "web application" flow step by step, without any third-party dependencies. Before running the code, register an OAuth app with GitHub, set a callback path, and expose your GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET as environment variables. The program maps out HTTP routes like this:

// These should be taken from your GitHub application settings
// at https://github.com/settings/developers
var GithubClientID = os.Getenv("GITHUB_CLIENT_ID")
var GithubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")

func main() {
  if len(GithubClientID) == 0 || len(GithubClientSecret) == 0 {
    log.Fatal("Set GITHUB_CLIENT_* env vars")
  }

  http.HandleFunc("/", rootHandler)
  http.HandleFunc("/login/", githubLoginHandler)
  http.HandleFunc("/github/callback/", githubCallbackHandler)

  addr := "localhost:8080"
  fmt.Printf("Listening on: http://%s\n", addr)
  log.Panic(http.ListenAndServe(addr, nil))
}

Visiting the root route shows a "Log in with GitHub" link, handled here:

const rootHTML = `
<h1>My web app</h1>
<p>Using raw HTTP OAuth 2.0</p>
<p>You can log into this app with your GitHub credentials:</p>
<p><a href="/login/">Log in with GitHub</a></p>
`

func rootHandler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprint(w, rootHTML)
}

Clicking the link routes the user to the login handler:

func githubLoginHandler(w http.ResponseWriter, r *http.Request) {
  // Step 1: Request a user's GitHub identity
  //
  // ... by redirecting the user's browser to a GitHub login endpoint. We're not
  // setting redirect_uri, leaving it to GitHub to use the default we set for
  // this application: /github/callback
  // We're also not asking for any specific scope, because we only need access
  // to the user's public information to know that the user is really logged in.
  //
  // We're setting a random state cookie for the client to return
  // to us when the call comes back, to prevent CSRF per
  // section 10.12 of https://www.rfc-editor.org/rfc/rfc6749.html
  state, err := randString(16)
  if err != nil {
    panic(err)
  }

  c := &http.Cookie{
    Name:     "state",
    Value:    state,
    Path:     "/",
    MaxAge:   int(time.Hour.Seconds()),
    Secure:   r.TLS != nil,
    HttpOnly: true,
  }
  http.SetCookie(w, c)

  redirectURL := fmt.Sprintf("https://github.com/login/oauth/authorize?client_id=%s&state=%s", GithubClientID, state)
  http.Redirect(w, r, redirectURL, 301)
}

This handler corresponds to step 1 of the documented flow. Note that the redirect_url is not passed explicitly here — the default configured at OAuth app registration time covers it. The handler also implements CSRF protection: it generates a random string, stores it in a cookie for the session, and includes the same value as the state parameter when redirecting to GitHub. When GitHub invokes your callback URL, it echoes this state back, allowing you to verify the request originates from a legitimate session.

The user then sees GitHub's sign-in screen:

Sign-in screenshot from GitHub

After credential verification, GitHub redirects the browser to the callback route: in this case http://localhost:8080/github/callback/. The callback handler's first half deals with step 2 of the flow:

func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
  // Step 2: Users are redirected back to your site by GitHub
  //
  // The user is authenticated w/ GitHub by this point, and GH provides us
  // a temporary code we can exchange for an access token using the app's
  // full credentials.
  //
  // Start by checking the state returned by GitHub matches what
  // we've stored in the cookie.
  state, err := r.Cookie("state")
  if err != nil {
    http.Error(w, "state not found", http.StatusBadRequest)
    return
  }
  if r.URL.Query().Get("state") != state.Value {
    http.Error(w, "state did not match", http.StatusBadRequest)
    return
  }

  // We use the code, alongside our client ID and secret to ask GH for an
  // access token to the API.
  code := r.URL.Query().Get("code")
  requestBodyMap := map[string]string{
    "client_id":     GithubClientID,
    "client_secret": GithubClientSecret,
    "code":          code,
  }
  requestJSON, err := json.Marshal(requestBodyMap)
  if err != nil {
    panic(err)
  }

  req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(requestJSON))
  if err != nil {
    panic(err)
  }
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Accept", "application/json")

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    http.Error(w, "unable to connect to access_token endpoint", http.StatusInternalServerError)
    return
  }
  respbody, _ := io.ReadAll(resp.Body)

  // Represents the response received from Github
  var ghresp struct {
    AccessToken string `json:"access_token"`
    TokenType   string `json:"token_type"`
    Scope       string `json:"scope"`
  }
  json.Unmarshal(respbody, &ghresp)

  // {...}

Once the CSRF token checks out, the handler sends the temporary code it received back to GitHub's access token endpoint, along with the shared secret, to obtain a bearer token. The second half of the handler:

func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
  // {...}

  // Step 3: Use the access token to access the API
  //
  // With the access token in hand, we can access the GitHub API on behalf
  // of the user. Since we didn't provide a scope, we only get access to
  // the user's public information.
  userInfo := getGitHubUserInfo(ghresp.AccessToken)

  w.Header().Set("Content-type", "application/json")
  fmt.Fprint(w, string(userInfo))
}

// getGitHubUserInfo queries GitHub's user API for information about the
// authorized user, given the access token received earlier.
func getGitHubUserInfo(accessToken string) string {
  // Query the GH API for user info
  req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
  if err != nil {
    panic(err)
  }
  req.Header.Set("Authorization", "Bearer "+accessToken)

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    panic(err)
  }

  respbody, _ := io.ReadAll(resp.Body)
  return string(respbody)
}

For demonstration purposes, the code dumps the logged-in user's data as JSON — a real application would likely extract the email address to use as a unique identifier in its own database.

Sample 2: Delegate with x/oauth2

The second sample pulls in golang.org/x/oauth2 to offload repeated OAuth legwork. Much of the code remains similar — the difference is where the hard work happens. The first useful piece is an OAuth config struct:

conf := &oauth2.Config{
        ClientID:     GithubClientID,
        ClientSecret: GithubClientSecret,
        Scopes:       []string{},
        Endpoint:     github.Endpoint,
}

Here, github refers to the x/oauth2/github subpackage. Its github.Endpoint constant is just an alias for the OAuth endpoint paths:

var GitHub = oauth2.Endpoint{
  AuthURL:       "https://github.com/login/oauth/authorize",
  TokenURL:      "https://github.com/login/oauth/access_token",
  DeviceAuthURL: "https://github.com/login/device/code",
}

That saves you from hard-coding the auth and token URLs. Inside githubLoginHandler, crafting the authorization URL is reduced to AuthCodeURL. Then, improvements show up on the callback side:

code := r.URL.Query().Get("code")
tok, err := lf.conf.Exchange(context.Background(), code) // 1: token exchange
if err != nil {
        log.Fatal(err)
}

// This client will have a bearer token to access the GitHub API on
// the user's behalf.
client := lf.conf.Client(context.Background(), tok)     // 2: client with token
resp, err := client.Get("https://api.github.com/user")
if err != nil {
        panic(err)
}
respbody, _ := io.ReadAll(resp.Body)
userInfo := string(respbody)

w.Header().Set("Content-type", "application/json")
fmt.Fprint(w, string(userInfo))

With x/oauth2, the "code for token" exchange is a one-liner (Exchange), and the library hands back an HTTP client pre-wired with the bearer token, so no manual HTTP setup is required. This sample ends up shorter with fewer embedded operations, striking a good balance between boilerplate and dependencies.

Sample 3: Middleware-driven gologin

Finally, the gologin package takes the abstraction a step further. It relies on x/oauth2 for its config, and its middleware handles nearly everything else. The main setup:

conf := &oauth2.Config{
        ClientID:     GithubClientID,
        ClientSecret: GithubClientSecret,
        Scopes:       []string{},
        Endpoint:     oauth2github.Endpoint,
}

// gologin has a default cookie configuration for debug deployments (no TLS).
cookieConf := gologin.DebugOnlyCookieConfig
http.HandleFunc("/", rootHandler)

loginHandler := github.LoginHandler(conf, nil)
http.Handle("/login/", github.StateHandler(cookieConf, loginHandler))

callbackHandler := github.CallbackHandler(conf, http.HandlerFunc(githubCallbackHandler), nil)
http.Handle(callbackPath, github.StateHandler(cookieConf, callbackHandler))

There are two immediate wins here:

  • gologin's StateHandler middleware handles the CSRF cookie mechanics automatically.
  • Its CallbackHandler middleware performs the token exchange with GitHub transparently, wrapping your own callback.

Your handler now simply grabs the user info from the request context:

func githubCallbackHandler(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  githubUser, err := github.UserFromContext(ctx)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }

  w.Header().Set("Content-type", "application/json")
  buf, _ := json.Marshal(githubUser)
  fmt.Fprint(w, string(buf))
}

That's effectively all there is to it — GitHub login becomes a matter of a handful of handler registrations and one context read.

Choosing your dependence level

Each of these samples accomplishes the same outcome, standing at a different point along the dependency spectrum. A purely stdlib approach gives you total visibility into the protocol; it is educational, but high-effort for production use. gologin, at the other extreme, hides almost everything behind middleware. The balanced middle ground — x/oauth2 — requires only modest code and is maintained by Go team members and contributors at Google, making it a "semi-official" option without being overly magical. For most projects, that is a reasonable sweet spot, unless you're comfortable trusting a well-established third-party library to manage the rest.