Building a Secure OIDC Verification Layer in Go
Authentication is one of those things every backend engineer eventually has to build—and secretly dreads getting wrong. When I started architecting the auth layer for my Go API gateway, I naturally reached for OpenID Connect (OIDC). It’s the industry standard, so how hard could it be?
As it turns out, parsing a JWT is trivial. Trusting a JWT is where the trapdoors hide. If you blindly decode a token and trust the payload, you are exactly one spoofed signature away from a major security incident. I needed a way to dynamically discover my identity provider’s keys, cryptographically verify incoming requests, and strictly enforce the claims my backend actually cares about (like ensuring a user actually owns the email address they claim to).
In this post, I’ll walk you through how I built the OIDC verification layer for my gateway. We won't just look at the happy path; we’ll look at why I used interfaces to keep the auth logic mockable, how to avoid network traps during startup, and why an unassuming email_verified pointer is your gateway’s best friend.
High-Level Flow
Before we dive into the Go code, here is the basic verification flow this architecture follows:
Client
|
| ID Token (JWT)
v
API Gateway
|
| 1. Discover OIDC provider metadata
| 2. Verify JWT signature (via JWKS)
| 3. Validate issuer and audience
| 4. Extract strongly-typed claims
| 5. Enforce application-specific rules (e.g., email verification)
v
Authenticated User
(Note: The complete source code for this example is available on my GitHub: dearai-backend)
Breaking Down the Implementation
Let's look at how this breaks down in Go, starting from the core domain types and moving into runtime validation.
1. Designing for Testability
package auth
import (
"context"
"errors"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
)
type TokenVerifier interface {
Verify(ctx context.Context, tokenString string) (*ExternalClaims, error)
}
Notice that the package exports a TokenVerifier interface rather than forcing consumers to use a concrete struct. This is classic dependency inversion.
When you're writing unit tests for your HTTP handlers or middleware, you really don't want them making live network calls to Google, Okta, or Auth0. By depending on this interface, you can easily inject a mock verifier that simply returns dummy claims for testing, keeping your test suite fast, offline, and deterministic.
2. Defining Expected Claims
type ExternalClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
}
type OIDCVerifier struct {
verifier *oidc.IDTokenVerifier
}
We map the JWT payload into an ExternalClaims struct. The sub (subject) claim should always be treated as your primary user identifier. It is immutable and stable, making it the perfect key to link to your internal user records. While it's tempting to use the user's email as a unique identifier, emails change, which can break data relationships later.
You'll also notice EmailVerified is defined as a *bool (a pointer to a boolean) rather than a primitive bool. This is a deliberate choice. It allows us to differentiate between a claim that is explicitly set to false and a claim that is completely missing from the token payload (which evaluates to nil). Both scenarios should result in a rejected token, and a pointer allows us to catch both cleanly.
3. Discovery and the Network Trap
func NewOIDCVerifier(ctx context.Context, issuer, clientID string) (TokenVerifier, error) {
if issuer == "" || clientID == "" {
return nil, errors.New("issuer and clientID are required")
}
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("failed to discover OIDC configuration: %w", err)
}
return &OIDCVerifier{
verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
}, nil
}
Before any network activity occurs, the constructor validates its inputs to fail fast. Then, we call oidc.NewProvider. This is where OIDC discovery happens. The library hits the issuer's .well-known/openid-configuration endpoint to fetch metadata, including the JWKS (JSON Web Key Set) URL which contains the public keys needed to verify token signatures.
This is the only place in the setup phase where network I/O occurs. Because of this, it is critical to pass a context.Context with a reasonable timeout into this function. If the identity provider goes down during a deployment and you don't have a timeout, your application startup will block indefinitely.
4. The Verification Pipeline
func (v *OIDCVerifier) Verify(ctx context.Context, tokenString string) (*ExternalClaims, error) {
idToken, err := v.verifier.Verify(ctx, tokenString)
if err != nil {
return nil, fmt.Errorf("cryptographic token verification failed: %w", err)
}
var claims ExternalClaims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal token claims: %w", err)
}
if claims.Subject == "" {
return nil, errors.New("invalid token: missing 'sub' claim")
}
if claims.Email == "" {
return nil, errors.New("invalid token: missing 'email' claim")
}
if claims.EmailVerified == nil || !*claims.EmailVerified {
return nil, errors.New("invalid token: email address is unverified")
}
return &claims, nil
}
The Verify method acts as our gatekeeper, enforcing rules in a very specific order.
First, we establish cryptographic trust by passing the raw token to v.verifier.Verify(). This leverages the provider's public keys to ensure the signature is mathematically valid, checks that the token hasn't expired, and verifies that the ClientID (Audience) matches your app. This last part is crucial—it prevents your gateway from accepting valid tokens that were actually minted for an entirely different application.
Once we trust the signature, we unmarshal the payload into our ExternalClaims struct to get strongly-typed Go values. Finally, we enforce our business logic trust. We ensure the sub and email aren't blank, and we evaluate that *bool to guarantee the identity provider has verified the user's email. If a token fails any of these steps, execution halts immediately and the request is dropped.
A Quick Note on Token Types
It’s worth mentioning that this specific implementation is designed to verify OIDC ID tokens, which are meant to communicate identity information (who the user is). Access tokens, on the other hand, are meant for API authorization (what the user can do) and often follow different validation rules depending on the provider. Make sure you are verifying the right token for your specific architecture.
Wrapping Up
Verifying an OIDC token requires a lot more rigor than just base64-decoding a JSON payload. By leveraging OIDC discovery and the go-oidc library, we get automatic key rotation handling and cryptographic certainty. Combine that with strictly typed claims and interface-driven design, and you end up with an auth layer that is secure, resilient, and highly testable.
Further Reading
If you'd like to see how this fits into the broader API gateway, or if you want to follow along with my future backend engineering articles:
- GitHub: github.com/Pixie2468
- LinkedIn: linkedin.com/in/darsh-ayde/