Build a Go Login API with JWT, TOTP and OAuth PKCE

Build a Go Login API with JWT, TOTP and OAuth PKCE

A login endpoint stops being simple once it has to handle two-factor authentication, token renewal and social sign-in. This tutorial builds a small Go service that connects those pieces: bcrypt password checks, short-lived JWTs, TOTP codes and an OAuth authorization-code flow protected with PKCE.

The example is a learning scaffold, not a production-ready account system. Its users and OAuth transaction state live in memory, so they disappear on restart and are not shared across instances. It also omits account recovery, session-management endpoints and provider-specific identity verification. Those limitations matter: authentication needs persistent, revocable sessions, not just signed tokens.

Set up the project

The service uses Gin for HTTP routing, golang-jwt for JWTs, bcrypt and TOTP packages from the Go ecosystem, and golang.org/x/oauth2 for the OAuth client. The OAuth provider endpoints and credentials come from environment variables rather than being invented for a particular provider.

mkdir go-auth-demo
cd go-auth-demo
go mod init example.com/go-auth-demo
go get github.com/gin-gonic/gin github.com/golang-jwt/jwt/v5 golang.org/x/crypto/bcrypt github.com/pquerna/otp golang.org/x/oauth2

Create the following files. For a quick local run, set a long random JWT secret and provide OAuth settings for an authorization server that supports PKCE. OAuth routes will return an error if those settings are absent.

Build password login and JWT validation

The first file holds a deliberately small in-memory user store. Passwords are bcrypt hashes, not plaintext. The JWT carries only a subject and expiry; it is signed with HS256, and the verifier explicitly requires that signing method. The example fixes the token lifetime at 15 minutes, following the short-lived-access-token approach described in the DEV Community post.

// main.go
package main

import (
	"crypto/rand"
	"encoding/base64"
	"errors"
	"log"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/pquerna/otp/totp"
	"golang.org/x/crypto/bcrypt"
	"golang.org/x/oauth2"
)

type User struct {
	Email    string
	Password []byte
	TOTP     string
	TwoFA    bool
}

type Store struct {
	mu    sync.Mutex
	users map[string]User
}

func newStore() *Store {
	return &Store{users: make(map[string]User)}
}

func (s *Store) add(email, password string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.users[email] = User{Email: email, Password: hash}
	return nil
}

func (s *Store) get(email string) (User, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[email]
	return u, ok
}

func (s *Store) setTOTP(email, secret string, enabled bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u := s.users[email]
	u.TOTP, u.TwoFA = secret, enabled
	s.users[email] = u
}

func signingKey() ([]byte, error) {
	v := os.Getenv("JWT_SECRET")
	if len(v) < 32 {
		return nil, errors.New("JWT_SECRET must contain at least 32 bytes")
	}
	return []byte(v), nil
}

func issueToken(email string) (string, error) {
	key, err := signingKey()
	if err != nil {
		return "", err
	}
	claims := jwt.RegisteredClaims{
		Subject:   email,
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
		IssuedAt:  jwt.NewNumericDate(time.Now()),
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return t.SignedString(key)
}

func validToken(raw string) (string, error) {
	key, err := signingKey()
	if err != nil {
		return "", err
	}
	t, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
		if t.Method != jwt.SigningMethodHS256 {
			return nil, errors.New("unexpected signing algorithm")
		}
		return key, nil
	})
	if err != nil {
		return "", err
	}
	claims, ok := t.Claims.(*jwt.RegisteredClaims)
	if !ok || !t.Valid || claims.Subject == "" {
		return "", errors.New("invalid token claims")
	}
	return claims.Subject, nil
}

func bearerUser(c *gin.Context) (string, bool) {
	header := c.GetHeader("Authorization")
	parts := strings.SplitN(header, " ", 2)
	if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
		return "", false
	}
	email, err := validToken(parts[1])
	return email, err == nil
}

type OAuthTxn struct {
	State    string
	Verifier string
	Expires  time.Time
}

var oauthMu sync.Mutex
var oauthTxns = make(map[string]OAuthTxn)

func randomString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func oauthConfig() (*oauth2.Config, error) {
	keys := []string{"OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET", "OAUTH_AUTH_URL", "OAUTH_TOKEN_URL", "OAUTH_REDIRECT_URL"}
	values := make([]string, len(keys))
	for i, key := range keys {
		values[i] = os.Getenv(key)
		if values[i] == "" {
			return nil, errors.New("OAuth environment configuration is incomplete")
		}
	}
	return &oauth2.Config{
		ClientID: values[0], ClientSecret: values[1],
		Endpoint: oauth2.Endpoint{AuthURL: values[2], TokenURL: values[3]},
		RedirectURL: values[4], Scopes: []string{"openid", "email"},
	}, nil
}

func main() {
	store := newStore()
	if err := store.add("[email protected]", "change-this-password"); err != nil {
		log.Fatal(err)
	}
	r := gin.Default()

	r.POST("/login", func(c *gin.Context) {
		var in struct {
			Email string `json:"email"`
			Password string `json:"password"`
		}
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, ok := store.get(in.Email)
		if !ok || bcrypt.CompareHashAndPassword(u.Password, []byte(in.Password)) != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
			return
		}
		if u.TwoFA {
			c.JSON(http.StatusAccepted, gin.H{"status": "totp_required", "email": u.Email})
			return
		}
		token, err := issueToken(u.Email)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "token unavailable"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"access_token": token, "token_type": "Bearer"})
	})

	r.GET("/me", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"email": email})
	})

	registerTOTP(r, store)
	registerOAuth(r)
	log.Fatal(r.Run(":8080"))
}

The demo account makes the login route testable immediately; change its password before using the service for anything beyond local testing. The JWT secret is checked when signing or parsing tokens, rather than silently falling back to a hard-coded value. A signed JWT is readable by whoever has it, so keep sensitive information out of its claims.

Add a TOTP enrollment and login challenge

Enrollment starts only for a user with a valid access token. The server returns an otpauth:// URL for the client to render as a QR code. The secret is pending until a valid code confirms that the authenticator is configured. For brevity, this demo keeps it in memory and does not encrypt it at rest.

The login route above returns a TOTP-required response for enabled accounts rather than issuing a token. The second route checks the password again along with the time-based code before issuing one. That keeps this example’s challenge bound to both factors, but it is not a substitute for a single-use, short-lived challenge credential and strict per-user rate limiting in a deployed system.

// totp.go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/pquerna/otp"
	"github.com/pquerna/otp/totp"
	"golang.org/x/crypto/bcrypt"
)

func registerTOTP(r *gin.Engine, store *Store) {
	r.POST("/2fa/setup", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		key, err := totp.Generate(totp.GenerateOpts{
			Issuer: "GoAuthDemo", AccountName: email, Digits: otp.DigitsSix,
		})
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create TOTP secret"})
			return
		}
		store.setTOTP(email, key.Secret(), false)
		c.JSON(http.StatusOK, gin.H{"otpauth_url": key.URL()})
	})

	r.POST("/2fa/enable", func(c *gin.Context) {
		email, ok := bearerUser(c)
		if !ok {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		var in struct { Code string `json:"code"` }
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, exists := store.get(email)
		if !exists || u.TOTP == "" || !totp.Validate(in.Code, u.TOTP) {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid code"})
			return
		}
		store.setTOTP(email, u.TOTP, true)
		c.JSON(http.StatusOK, gin.H{"status": "enabled"})
	})

	r.POST("/2fa/login", func(c *gin.Context) {
		var in struct {
			Email string `json:"email"`
			Password string `json:"password"`
			Code string `json:"code"`
		}
		if c.ShouldBindJSON(&in) != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
			return
		}
		u, exists := store.get(in.Email)
		if !exists || !u.TwoFA || bcrypt.CompareHashAndPassword(u.Password, []byte(in.Password)) != nil || !totp.Validate(in.Code, u.TOTP) {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials or code"})
			return
		}
		token, err := issueToken(u.Email)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "token unavailable"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"access_token": token, "token_type": "Bearer"})
	})
}

There is no disable-2FA route here: disabling should require re-authentication, and a complete implementation should provide recovery codes for users who lose their authenticator. TOTP verification also needs a tight rate limit. The DEV Community example suggests a stricter limit for 2FA attempts than for ordinary login, keyed by both IP and user or session. An in-memory counter would not be shared across multiple service instances.

Wire OAuth authorization code flow with PKCE

The final file demonstrates the redirect and callback mechanics. Each start request creates a random state and PKCE verifier, then stores them briefly. The callback checks state before exchanging the authorization code with the verifier. The Go OAuth package documents both S256ChallengeOption and VerifierOption; RFC 9700 recommends PKCE for authorization-code clients and says clients must prevent CSRF.

This callback deliberately does not create or link an account: that step requires validating the provider’s identity response and applying a careful account-linking policy. In particular, the DEV Community post warns against automatically linking accounts by email unless the provider confirms that address is verified. The code also returns no provider tokens to the browser.

// oauth.go
package main

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

func registerOAuth(r *gin.Engine) {
	r.GET("/oauth/start", func(c *gin.Context) {
		conf, err := oauthConfig()
		if err != nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
			return
		}
		state, err := randomString(32)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "random generation failed"})
			return
		}
		verifier := oauth2.GenerateVerifier()
		oauthMu.Lock()
		oauthTxns[state] = OAuthTxn{State: state, Verifier: verifier, Expires: time.Now().Add(5 * time.Minute)}
		oauthMu.Unlock()
		c.Redirect(http.StatusFound, conf.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)))
	})

	r.GET("/oauth/callback", func(c *gin.Context) {
		conf, err := oauthConfig()
		if err != nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
			return
		}
		state := c.Query("state")
		oauthMu.Lock()
		txn, exists := oauthTxns[state]
		delete(oauthTxns, state)
		oauthMu.Unlock()
		if !exists || txn.State != state || time.Now().After(txn.Expires) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
			return
		}
		tok, err := conf.Exchange(c.Request.Context(), c.Query("code"), oauth2.VerifierOption(txn.Verifier))
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization exchange failed"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"status": "provider authorization received", "token_type": tok.TokenType})
	})
}

Run the service with a locally generated secret and try password login. Then use the returned bearer token for TOTP setup, scan the returned URI with an authenticator, enable 2FA with its current code, and test the two-factor login route.

export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-bytes"
go run .

Before taking it beyond a demo

  • Persist and revoke sessions. Add refresh-token rotation and store only a hash of each refresh token. Track sessions so users can end one or all of them. A stateless access JWT cannot be revoked early unless requests also check server-side state.
  • Use shared rate limits. Limit login, OAuth initiation and 2FA verification. The example’s in-memory maps do not coordinate across instances; the DEV Community post recommends a shared store such as Redis when scaling out.
  • Protect secrets and browser tokens. Use a securely generated JWT key, protect TOTP secrets at rest, and avoid putting tokens in redirect URLs. If using cookies, account for CSRF protection.
  • Finish OAuth identity handling. Check the provider response and verified-email status before linking or creating a local account. Register exact redirect URIs; RFC 9700 calls for exact matching, with a limited localhost exception for native apps.

This starter issues access tokens only after password verification and, when enabled, a valid TOTP code. Its OAuth callback checks state and PKCE before accepting the provider’s authorization response. Persistent sessions, recovery and provider identity validation remain the next concrete pieces to implement.

Sources: DEV Community, RFC Editor / IETF, Go Packages, OWASP