DEV Community

Implementing OpenID Connect (OIDC) from Scratch in Go

Most teams drop in coreos/go-oidc and call it done. That works until you hit a non-standard provider, a subtle validation bypass, or a token leakage bug you can't explain because you never read the spec.

Building OIDC from scratch takes about 300 lines of Go - this article walks through every step.

What the Authorization Code Flow Actually Does

OIDC sits on top of OAuth 2.0 and adds identity. Where OAuth 2.0 handles authorization (can this client access this resource?), OIDC adds authentication: who is this user, and can I trust that claim?

The Authorization Code Flow has four moves:

  • Redirect the user to the provider's authorization endpoint with a state and nonce
  • The provider authenticates the user and redirects back to your app with a code
  • Your server exchanges the code for tokens - never the browser, so the secret stays server-side
  • You validate the id_token JWT - this is where most bugs live

The state parameter is a random value you store in the session before redirecting. When the provider sends the user back, you verify it matches - that is your CSRF defense.

The nonce is embedded in the ID token and binds a specific token to a specific login session, preventing replay attacks.

Fetching Provider Configuration

Every compliant OIDC provider publishes a discovery document at /.well-known/openid-configuration. Start there - it gives you all the endpoints without hardcoding them.

type ProviderMetadata struct {
  Issuer string `json:"issuer"`
  AuthorizationEndpoint string `json:"authorization_endpoint"`
  TokenEndpoint string `json:"token_endpoint"`
  JWKSURI string `json:"jwks_uri"`
}

func fetchProviderMetadata(issuerURL string) (*ProviderMetadata, error) {
  url := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
  resp, err := http.Get(url)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()
  var meta ProviderMetadata
  if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
    return nil, err
  }
  if meta.Issuer != issuerURL {
    return nil, fmt.Errorf("issuer mismatch: got %s, want %s", meta.Issuer, issuerURL)
  }
  return &meta, nil
}

The issuer check matters. A misconfigured or malicious provider could return a discovery document claiming a different issuer - catching this early prevents token confusion attacks where tokens from one provider are accepted by another.

Once you have the metadata, build the authorization URL. Generate cryptographically random values for both state and nonce, and store them in the session before redirecting:

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

func buildAuthURL(meta *ProviderMetadata, clientID, redirectURI, state, nonce string) string {
  params := url.Values{}
  params.Set("response_type", "code")
  params.Set("client_id", clientID)
  params.Set("redirect_uri", redirectURI)
  params.Set("scope", "openid email profile")
  params.Set("state", state)
  params.Set("nonce", nonce)
  return meta.AuthorizationEndpoint + "?" + params.Encode()
}

Do not skip nonce. It binds the ID token to your specific authorization request and prevents an attacker from injecting a captured ID token from a different flow into yours.

Exchanging the Code for Tokens

When the user returns to your redirect URI, verify that state matches what you stored in the session, then POST the code to the token endpoint:

type TokenResponse struct {
  AccessToken string `json:"access_token"`
  IDToken string `json:"id_token"`
  TokenType string `json:"token_type"`
  ExpiresIn int `json:"expires_in"`
}

func exchangeCode(meta *ProviderMetadata, clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) {
  data := url.Values{}
  data.Set("grant_type", "authorization_code")
  data.Set("code", code)
  data.Set("redirect_uri", redirectURI)
  data.Set("client_id", clientID)
  data.Set("client_secret", clientSecret)
  req, _ := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(data.Encode()))
  req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()
  if resp.StatusCode != http.StatusOK {
    body, _ := io.ReadAll(resp.Body)
    return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, body)
  }
  var tokens TokenResponse
  return &tokens, json.NewDecoder(resp.Body).Decode(&tokens)
}

This is a confidential client flow - the client_secret is sent from your server to the token endpoint. Public clients (SPAs, mobile apps) cannot safely hold a client_secret and must use PKCE instead. Never send client_secret from browser-side code.

Validating the ID Token

The id_token is a JWT signed by the provider. This is where most hand-rolled implementations get something wrong. The validation steps are:

  • Parse the JWT header to find which key ID (kid) was used to sign it
  • Fetch the provider's JWKS and find the matching public key
  • Verify the signature with that key
  • Check the iss, aud, exp, and nonce claims
func validateIDToken(rawToken, jwksURI, issuer, clientID, expectedNonce string) (map[string]any, error) {
  parts := strings.Split(rawToken, ".")
  if len(parts) != 3 {
    return nil, errors.New("malformed JWT")
  }
  headerJSON, _ := base64.RawURLEncoding.DecodeString(parts[0])
  var header struct {
    Alg string `json:"alg"`
    Kid string `json:"kid"`
  }
  if err := json.Unmarshal(headerJSON, &header); err != nil {
    return nil, err
  }
  if header.Alg != "RS256" {
    return nil, fmt.Errorf("unsupported algorithm: %s", header.Alg)
  }
  key, err := fetchPublicKey(jwksURI, header.Kid)
  if err != nil {
    return nil, err
  }
  message := parts[0] + "." + parts[1]
  sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
  h := crypto.SHA256.New()
  h.Write([]byte(message))
  if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, h.Sum(nil), sig); err != nil {
    return nil, fmt.Errorf("invalid signature: %w", err)
  }
  claimsJSON, _ := base64.RawURLEncoding.DecodeString(parts[1])
  var claims map[string]any
  if err := json.Unmarshal(claimsJSON, &claims); err != nil {
    return nil, err
  }
  if claims["iss"] != issuer {
    return nil, fmt.Errorf("issuer mismatch")
  }
  if claims["aud"] != clientID {
    return nil, fmt.Errorf("audience mismatch")
  }
  if exp, ok := claims["exp"].(float64); !ok || time.Now().Unix() > int64(exp) {
    return nil, fmt.Errorf("token expired")
  }
  if claims["nonce"] != expectedNonce {
    return nil, fmt.Errorf("nonce mismatch")
  }
  return claims, nil
}

A few production notes. First, explicitly reject non-RS256 algorithms - the "alg": "none" exploit is not theoretical, and some libraries historically accepted it.

Second, cache the JWKS keys with their Cache-Control TTL rather than fetching on every request.

Third, add a small clock skew tolerance (30 seconds is conventional) on the exp check to handle minor time drift between servers.

Fourth, if the provider returns multiple audiences, the spec says aud can be an array - handle both string and []string in your type assertion.

The nonce check is often the first thing dropped when developers hit edge cases. Do not drop it - it is what prevents a captured valid token from being replayed in a different user session.

For a reference checklist of the full set of JWT and OIDC validations that security auditors look for, the free security hardening checklists at AYI NEDJIMI Consultants cover OIDC hardening alongside TLS, secrets management, and other controls in PDF and Excel format.

The Takeaway

OIDC is not complicated, but the security surface is in the details. The three things most commonly missing in hand-rolled implementations:

  • Nonce validation - binds the token to the specific login flow; skipping it enables replay attacks
  • Strict issuer and audience checks - prevents token confusion between providers or between apps sharing the same provider tenant
  • Algorithm enforcement - explicitly reject anything that is not RS256 or ES256; never trust the alg claim from an untrusted token blindly

Building this once without a library teaches you exactly what coreos/go-oidc does under the hood, where its defaults are safe, and where configuration choices matter. After that, using the library is the right call - but you will know what to audit when a CVE drops.

I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists - PDF and Excel.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.