APIs for OAuth2, SSO, SAML, OpenID Connect
"Sign in with Google" buttons, corporate single sign-on portals, and API access tokens all rest on a small set of standards that are frequently confused with each other. OAuth2 is an authorization framework — it lets a user grant a third-party app limited access to their data without handing over their password. OpenID Connect is a thin authentication layer built on top of OAuth2 — it answers "who is this user," not just "what can this app access." SAML is an older, XML-based single sign-on standard still common in enterprise environments. This chapter builds working Go code against the two you'll meet most often — OAuth2 and OpenID Connect — and explains where SAML fits without drowning in its XML.
OAuth2: delegated authorization
The core OAuth2 flow your API will implement most often is the authorization code flow: your app redirects the user to a provider (Google, GitHub), the user approves access, the provider redirects back with a short-lived code, and your server exchanges that code for an access token. Go's golang.org/x/oauth2 package (maintained by the Go team) handles the token exchange mechanics:
package main
import (
"context"
"net/http"
"os"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var oauthConfig = &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://myapp.com/auth/callback",
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
state := generateRandomState()
setStateCookie(w, state)
url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusFound)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != readStateCookie(r) {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
clearStateCookie(w) // one-time use: never valid for a second callback
code := r.URL.Query().Get("code")
token, err := oauthConfig.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
client := oauthConfig.Client(r.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
// ... read the profile, create or find a local user, set your own session
}
The state parameter is not optional decoration — it's what prevents a cross-site request forgery attack where an attacker tricks a victim into completing an OAuth flow the attacker initiated. Always generate it randomly per login attempt, store it (a short-lived cookie is fine), reject the callback if it doesn't match, and clear it immediately after a successful match so the same state value can never be replayed against a second callback.
ClientSecret genuinely secret, but a mobile app or single-page app cannot — anything shipped to a user's device can be decompiled or inspected. For those "public" clients, add PKCE (Proof Key for Code Exchange, RFC 7636): generate a random code_verifier, send its SHA-256 hash as code_challenge in the authorization request, and present the original code_verifier during the token exchange so the provider can confirm the app completing the exchange is the same one that started it — even without a client secret. Go's golang.org/x/oauth2 package supports this via oauth2.S256ChallengeOption and oauth2.VerifierOption.OpenID Connect: identity, formally
OpenID Connect (OIDC) adds a standardized ID token — a signed JWT containing the user's identity claims — plus a discovery document and a defined way to verify the token's signature. The github.com/coreos/go-oidc/v3/oidc package (widely used, despite the CoreOS name predating its current maintenance) wraps this verification:
import "github.com/coreos/go-oidc/v3/oidc"
func setupOIDC(ctx context.Context) (*oidc.IDTokenVerifier, error) {
provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
if err != nil {
return nil, err
}
return provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID}), nil
}
func verifyIDToken(
ctx context.Context, verifier *oidc.IDTokenVerifier, rawIDToken string,
) (*oidc.IDToken, error) {
return verifier.Verify(ctx, rawIDToken)
}
oidc.NewProvider fetches the provider's .well-known/openid-configuration document, which points to the JSON Web Key Set (JWKS) used to verify token signatures — you never hardcode the provider's public key, because it rotates. verifier.Verify checks the signature, the issuer, the audience (your client ID), and the expiry all in one call. Once verified, idToken.Claims(&myStruct) decodes the payload — typically sub (a stable user identifier), email, and email_verified.
SAML: enterprise single sign-on
SAML predates OAuth2 and OIDC and is still the default in many enterprise identity providers (Okta, Azure AD, OneLogin) for internal application single sign-on. It exchanges signed XML assertions instead of JWTs, over a browser redirect (or POST) rather than a token endpoint. Implementing the protocol by hand is not something you want to attempt — the github.com/crewjam/saml/samlsp package is the standard choice in Go and handles the metadata exchange, assertion signature verification, and session middleware for you:
import "github.com/crewjam/saml/samlsp"
middleware, err := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: privateKey,
Certificate: cert,
IDPMetadata: idpMetadata,
})
mux.Handle("/saml/", middleware)
mux.Handle("/secure/", middleware.RequireAccount(secureHandler))
For a typical Go API, SAML is usually something you integrate at the edge (via samlsp's middleware) rather than something you implement from scratch — the value it adds over OIDC in most modern stacks is compatibility with legacy enterprise identity providers, not any technical advantage.
Choosing between them
If you're building "sign in with X" for consumer-facing users, use OIDC — it's simpler, JSON-based, and what every modern identity provider supports as a first-class citizen. If you're integrating with an enterprise customer's existing identity provider and they hand you SAML metadata instead of an OIDC discovery URL, reach for samlsp rather than trying to convince their IT department to support something else. Plain OAuth2 without an identity layer is appropriate when you genuinely only need delegated API access (posting to a user's Twitter account on their behalf) and don't need to know who they are beyond that access being granted.
Refresh tokens and long-lived sessions
Access tokens are deliberately short-lived, which means a real integration needs a plan for renewing them without forcing the user through the login screen again every fifteen minutes. oauth2.Config.TokenSource wraps a stored refresh token and handles renewal transparently:
func clientFromStoredToken(ctx context.Context, stored *oauth2.Token) *http.Client {
tokenSource := oauthConfig.TokenSource(ctx, stored)
return oauth2.NewClient(ctx, tokenSource)
}
Every call made through the returned client checks whether the current access token has expired and, if so, uses the refresh token to fetch a new one automatically before the request goes out — your application code never has to check expiry itself. The one operational responsibility this pushes onto you is persisting the refreshed token: tokenSource.Token() returns the current token including any renewal, and if your storage doesn't get updated when that happens, the next process restart falls back to the stale one.
Frequently Asked Questions
If a user is already authenticated with an OAuth2 access token, why do I still need OpenID Connect? Because OAuth2 was never designed to answer "who is this," only "what has this app been granted." An access token lets your server call the userinfo endpoint on the provider's terms, but that endpoint is a convention, not a spec-guaranteed contract, and nothing about the token itself is signed proof of identity you can verify offline. OIDC's ID token is a signed JWT you can verify yourself against the provider's JWKS, which is why it's the right tool the moment "log this user in" is the actual goal.
Why store the sub claim instead of the email address as my local user's key?
Because email addresses are mutable — a user can change providers, change their email, or have it recycled by their employer after they leave — while sub is a stable identifier the provider guarantees won't be reassigned to someone else. Keying your foreign key off email invites a bug where a login three years from now silently resolves to the wrong account.
My callback handler works fine locally but fails in production with a state mismatch error — what's going wrong?
The most common cause is the state cookie not surviving the redirect, usually because of a Secure or SameSite cookie attribute mismatch between your local HTTP setup and production HTTPS, or a load balancer routing the login and callback requests to different backend instances that don't share cookie storage. Double-check that the cookie set in loginHandler uses attributes compatible with your production domain and that state isn't being stored in a way that assumes a single sticky server.
Do I need to implement SAML myself if an enterprise customer asks for it?
No — reach for github.com/crewjam/saml/samlsp rather than hand-rolling XML assertion parsing and signature verification, which is exactly the kind of security-critical parsing you don't want to get subtly wrong. Treat SAML as something you integrate at the edge via that middleware, the same way you'd treat OIDC as a library concern rather than something you reimplement from the discovery document up.
Can I skip refresh tokens entirely and just make the user log in again when their access token expires?
You can, and for a low-traffic internal tool that might even be the simpler choice, but for anything user-facing it trades a small amount of implementation effort for a real usability cost — access tokens are deliberately short-lived (often fifteen minutes to an hour), so without refresh tokens your users would be redirected to a login screen constantly. oauth2.Config.TokenSource handles the renewal transparently enough that there's rarely a good reason to skip it once you're already storing tokens.