Skip to content

Issuer ​

An issuer is the OP's identity URL. The same string appears in several places at once:

  • the iss claim of every token the OP signs,
  • the issuer field of /.well-known/openid-configuration,
  • the iss of every JWKS entry's parent metadata,
  • and — depending on the profile — the aud of the client_assertion a confidential client signs to authenticate.

It is the single string that ties a token, a signing key, and a discovery document back to the same OP.

Mental model in 30 seconds

  • The issuer is a URL, not just a hostname. https://login.example.com is an issuer; login.example.com is not.
  • RPs compare it byte-for-byte. https://login.example.com and https://login.example.com/ are different issuers as far as a strict RP is concerned.
  • This library refuses to boot on a non-canonical value rather than silently normalising it. The shape you pass to op.WithIssuer is the shape every emitted artefact will use.
Specs referenced on this page

Why canonical form matters ​

OIDC Discovery 1.0 §4 derives the configuration URL by removing any terminating slash from the issuer and inserting /.well-known/openid-configuration between the host and the issuer path. RFC 9207 then requires the OP to echo the same iss on authorization responses so a misrouted code cannot be silently used against the wrong AS — the so-called "mix-up" defence. Both checks rely on the OP and every RP agreeing on one canonical spelling.

Real deployments commonly produce two URIs that look equivalent but are not:

DriftLooks likeWhy it breaks
Trailing slashhttps://idp.example.com/ vs https://idp.example.comDiscovery strips a terminating slash when constructing the well-known URL, but these are different issuer strings for exact comparison. The library accepts only the latter.
Default porthttps://idp.example.com:443 vs https://idp.example.comThe byte-for-byte iss comparison on the RP side fails the moment the OP starts emitting one shape and a load balancer rewrites to the other.
Mixed-case schemeHTTPS://idp.example.comURL parsers normalise inconsistently. Some downcase the scheme during parsing, some do not — an RP that compares the raw string and one that compares the parsed form see two different issuers.
Mixed-case hosthttps://Idp.Example.comHostnames resolve without regard to case, but exact issuer-string comparisons can disagree if components normalize them differently.
Non-canonical pathhttps://idp.example.com//oidc or .../a/../bNormalizing a path in one component but retaining it in another creates different issuer strings. The library rejects these forms.
Fragmenthttps://idp.example.com#mainA fragment has no role in HTTP request routing; carrying one in iss is structurally meaningless.
Query stringhttps://idp.example.com?env=prodThe discovery URL would fold into a malformed concatenation; the spec forbids it.

A spelling mismatch can break exact issuer checks, including RFC 9207 mix-up validation; it does not by itself invalidate a JWT signature. The class of bug is usually invisible until production traffic from one RP reaches the OP through a CDN or alternate hostname.

What this library refuses at boot ​

op.New validates the issuer during its construction pass and returns an error rather than starting on a malformed value. The rules enforced today:

  • Must be a non-empty, parseable URL. An empty string or a string that fails url.Parse is rejected.
  • Must be absolute and carry an authority. oidc.example.com (no scheme) and https:///path (no host) both fail.
  • Must not carry a query. A ? in the URL is rejected — the discovery URL would fold into a malformed concatenation.
  • Must not carry a fragment. A # in the URL is rejected for the same reason.
  • Must not end with a trailing slash on the path. Both https://idp.example.com/ and https://idp.example.com/oidc/ are rejected: the issuer's path is spliced into the discovery URL, and a trailing slash yields a second, non-canonical spelling of the same document.
  • Scheme must be all-lowercase. HTTPS://idp.example.com and HtTpS://idp.example.com are rejected. The validator inspects the raw input (not just the parsed form) so a parser that downcases the scheme during normalisation cannot mask the drift.
  • Host must be all-lowercase. https://IDP.example.com and https://IDP.EXAMPLE.COM/oidc are rejected. u.Host preserves raw casing, so any uppercase letter in the authority fails the check.
  • Must omit the default port. https://idp.example.com:443, https://idp.example.com:443/oidc, and http://127.0.0.1:80 are rejected. A redundant default port flips byte-equality the moment a load balancer canonicalises one side and not the other.
  • Path must be canonical. .. segments, . segments, and duplicate slashes (https://idp.example.com/a/../b, https://idp.example.com/a/./b, https://idp.example.com//oidc) are rejected. The validator runs path.Clean and refuses anything that does not round-trip.
  • Must use https. The only carve-out is loopback IP literals (127.0.0.0/8 and [::1]), which may use plain http so a development boot does not need TLS. The textual host localhost is not in the default carve-out. This is a library development policy; RFC 8252 §7.3 concerns native-app redirect URIs, not issuer URLs. op.WithAllowLocalhostLoopback() admits it explicitly, which a local passkey deployment needs: a WebAuthn Relying Party ID must be a domain, so an issuer on 127.0.0.1 has no valid RP ID to pair with. The rebinding reasoning does not stop applying — the opt-in is for a developer machine and nowhere else. Because of it the issuer is validated during op.New's validation pass rather than inside WithIssuer, so the carve-out is seen whichever order the options are given in.

These rules together guarantee iss is byte-exact across the discovery document, every issued token, and the authorization-response iss parameter — the form RPs compare under RFC 9207 mix-up defence.

Anything that passes those checks is accepted as the canonical form. Whatever you pass is the value reused for iss in every emitted artefact, for the discovery document's own issuer field, and for one of the client_assertion audiences this library currently accepts. The final FAPI 2.0 Security Profile §5.3.2.1(8) permits only the issuer identifier as a string; the library also accepts the token endpoint URL or an array, so its preset alone does not establish final-profile conformance.

How to set it ​

go
provider, err := op.New(
    op.WithIssuer("https://login.example.com"),
    // ... other options
)

A subpath issuer is fine for multi-tenant deployments. The issuer's path is part of the public endpoint namespace, not just metadata, so op.New mounts every protocol handler beneath it: an issuer of https://login.example.com/tenant-a serves the token endpoint at /tenant-a/oidc/token and the rest to match.

The discovery document is the one exception, and it is easy to get backwards. OpenID Connect Discovery 1.0 §4 inserts the well-known suffix between the host and the issuer path, not after it:

URL
Issuerhttps://login.example.com/tenant-a
Discovery documenthttps://login.example.com/.well-known/openid-configuration/tenant-a
Token endpointhttps://login.example.com/tenant-a/oidc/token

The library mounts and advertises the same URLs, so an RP that follows discovery never sees the difference — but an operator writing proxy rules by hand does. The trailing-slash rule still applies: https://login.example.com/tenant-a is canonical; https://login.example.com/tenant-a/ is not.

One value across the lifetime of the deployment

Once RPs have stored your issuer string, every change is a breaking change for them. Plan the canonical form before the first RP integrates: pick a hostname you will not need to retire, decide the subpath shape, and never let a load balancer rewrite the host or scheme on its way through. The library catches most boot-time mistakes; it cannot catch a TLS-terminating proxy that strips the path or downcases the host downstream.