Articles / Security

  • oidc
  • oauth2
  • authentication
  • go
  • jwt
  • debugging

Two OIDC Providers, Two Readings of the Spec, One Broken Login

You add "Sign in with Provider B" to an app that has worked with Provider A for months, and login fails with a one-line error: invalid issuer, or audience mismatch, or a JSON unmarshalling failure on a claim you have never had trouble with. Both providers say they implement OpenID Connect. Both are telling the truth. The spec just leaves enough room that "compliant" does not mean "interchangeable".

This is a tour of the places where that room gets used, and how to write a verifier that survives it without turning into a pile of per-vendor special cases.

The issuer must match exactly, including the slash

The iss claim in an ID token has to be identical to the issuer value in the provider's discovery document. Identical means a string comparison, not a URL comparison. The discovery spec says the issuer in the document must be exactly the one used to build the discovery URL, and the Core spec says the same of the token.

So these are different issuers:

  • https://accounts.example.com
  • https://accounts.example.com/

Some providers publish a trailing slash and some do not. If your config says "issuer: https://tenant.example.com" and the provider emits the slash, a "helpful" normaliser that strips it will make your comparison pass while your discovery fetch fails, or the other way round. Take the issuer from configuration, fetch discovery from it, and compare the token to the discovery value byte for byte.

Quick detour: Google's two issuers

Google's documentation says its ID tokens may carry either accounts.google.com or https://accounts.google.com as the issuer. That is the one famous case where a strict single-string match is wrong, and it is why you will see a hard-coded two-element allowlist in plenty of libraries. It is a deliberate exception, not a licence to loosen the check for everyone.

Multi-tenant issuers are templates

Microsoft's multi-tenant endpoints publish an issuer containing a literal placeholder, something like https://login.microsoftonline.com/{tenantid}/v2.0. No token will ever contain that string. A generic client that compares naively fails every single login.

The correct handling is to substitute the tenant ID from the token's own tid claim, then compare. And then decide whether you actually accept that tenant, because "any Entra tenant on Earth" is rarely the intended audience. Verifying the signature and issuer proves the token came from some tenant. It does not prove it came from yours.

The audience is a string or an array

RFC 7519 allows aud to be a single string or an array of strings. Most providers send a string when there is one audience. Some send an array even then, and the moment a token has more than one intended recipient, everyone does.

Go's encoding/json will refuse to decode an array into a string field, which is why this surfaces as an unmarshalling error rather than an authentication one. A small type fixes it for good:

type audience []string

func (a *audience) UnmarshalJSON(b []byte) error {
	var one string
	if err := json.Unmarshal(b, &one); err == nil {
		*a = audience{one}
		return nil
	}
	var many []string
	if err := json.Unmarshal(b, &many); err != nil {
		return fmt.Errorf("aud must be a string or array of strings: %w", err)
	}
	*a = many
	return nil
}

func (a audience) contains(clientID string) bool {
	return slices.Contains(a, clientID)
}

Then check that your client ID is in the list. If the array has several entries, the Core spec also has things to say about azp, the authorised party; check it when it is present.

Booleans that arrive as strings

The email_verified claim is meant to be a JSON boolean. Providers have been seen sending the string "true" instead, particularly from userinfo endpoints. Others omit it entirely, and some only send an email claim you should not trust at all.

type looseBool bool

func (l *looseBool) UnmarshalJSON(b []byte) error {
	switch string(b) {
	case "true", `"true"`:
		*l = true
	case "false", `"false"`, "null":
		*l = false
	default:
		return fmt.Errorf("unexpected boolean value %s", b)
	}
	return nil
}

Note the direction of the failure. Anything unrecognised is an error, and a missing claim is false. Never default a missing verification flag to "verified".

The sub claim is only unique per issuer

This one is the quiet bug. Two providers can legitimately hand out the same sub value, because each only promises uniqueness within itself. If your users table is keyed on sub alone, a user from Provider B can land in the account of a user from Provider A.

Key identities on the pair (iss, sub):

type Identity struct {
	Issuer  string
	Subject string
}

// users is keyed by Identity, never by Subject alone.
var users = map[Identity]int64{}

Do not link accounts by email

The tempting shortcut is "same email, same person, merge them". It is also how accounts get taken over. Some providers do not verify the email claim at all; Microsoft's own documentation warns that it is mutable and not to be used for authorisation or identity decisions. An attacker who can set an unverified address on their own tenant gets a token that says [email protected].

If you must link by email, require email_verified to be a real true from a provider you trust to mean it, and make the user prove control of the existing account first.

A checklist for the verifier

  • Fetch discovery from the configured issuer; compare iss to the discovery value exactly.
  • Handle templated issuers by substituting tid, then restrict which tenants you accept.
  • Decode aud as string-or-array and look for your client ID.
  • Check exp, the nonce you sent, and the signature against keys from the provider's jwks_uri.
  • Treat email_verified leniently in type, strictly in meaning.
  • Store identities as (iss, sub).

If you would rather not write the signature and key-rotation parts yourself, a maintained library such as github.com/coreos/go-oidc handles them, and its documented per-issuer options cover the Google case. The claim-shape handling above is still worth keeping in your own code, because that is where your application's trust decisions live.

The pattern behind every item on that list: the spec describes what a well-behaved token looks like, and your code has to decide what to do with a token that is merely close. Be tolerant about shape, and exact about identity.