Skip to content

Issuer ​

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

  • the iss claim of every token the OP signs,
  • the iss parameter on every authorization response (RFC 9207),
  • the issuer field of the discovery document.

A confidential client may also use it as the aud of the client_assertion it signs to authenticate. The issuer is the one 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 string you pass to op.WithIssuer is the string every emitted token and document will carry.
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 appending /.well-known/openid-configuration. RFC 9207 then has the OP echo the same iss on every authorization response, so a code delivered by the wrong authorization server is detected instead of being redeemed against the wrong one. This is the "mix-up" defence. Both checks rely on the OP and every RP agreeing on one canonical spelling.

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

DriftLooks likeWhy it breaks
Trailing slashhttps://idp.example.com/ vs https://idp.example.comDiscovery strips the slash when it builds the well-known URL, but the two are different issuer strings under exact comparison. The library accepts only the latter.
Default porthttps://idp.example.com:443 vs https://idp.example.comThe RP's byte-for-byte iss comparison fails as soon as the OP emits one form and a load balancer rewrites it to the other.
Mixed-case schemeHTTPS://idp.example.comSome URL parsers downcase the scheme and some do not, so an RP comparing the raw string and one comparing the parsed form see two different issuers.
Mixed-case hosthttps://Idp.Example.comHostnames resolve without regard to case, but an exact string comparison does not, and components that normalise the host differently disagree.
Non-canonical pathhttps://idp.example.com//oidc or .../a/../bA component that normalises the path and one that keeps it verbatim produce different issuer strings. The library rejects these forms.
Fragmenthttps://idp.example.com#mainA fragment is never sent in an HTTP request, so it has no meaning inside iss.
Query stringhttps://idp.example.com?env=prodAppending the well-known suffix would produce a malformed URL; the spec forbids a query.

A spelling mismatch breaks exact issuer checks, including RFC 9207 mix-up validation, but it does not invalidate a JWT signature. The bug usually stays hidden until production traffic from one RP reaches the OP through a CDN or an alternate hostname.

What this library refuses at boot ​

op.New validates the issuer during its construction pass and returns an error instead of starting on a malformed value. The rules:

  • 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, because the discovery URL would be malformed.
  • Must not carry a fragment. A # in the URL is rejected for the same reason.
  • Must not end with a trailing slash. Both https://idp.example.com/ and https://idp.example.com/oidc/ are rejected: the issuer is concatenated with the well-known suffix, 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, so a parser that downcases the scheme cannot hide 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 one 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 part of it, because a name resolved through DNS can be hijacked (RFC 8252 §8.3). op.WithAllowLocalhostIssuerForDev() admits localhost as the issuer explicitly (it requires an http issuer on localhost, such as http://localhost:8080), and a local passkey deployment needs it: 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. Use it on a developer machine only. (localhost in redirect URIs is a separate option, op.WithAllowLocalhostRedirectURIs(); see Redirect URI.) Because the option can change the verdict, the issuer is checked in op.New's validation pass rather than inside WithIssuer, and the order of options does not matter.

Together these rules make iss byte-identical across the discovery document, every issued token and the iss parameter of authorization responses, which is the form RPs compare under RFC 9207.

Whatever passes the checks is used verbatim as iss, as the discovery document's issuer, and as one of the client_assertion audiences the library accepts. FAPI 2.0 Security Profile §5.3.2.1(8) allows only the issuer identifier as a JSON string. Under profile.FAPI2Baseline and profile.FAPI2MessageSigning the library enforces that; other profiles also accept the token endpoint URL and an array. See Design judgments — client_assertion audience.

How to set it ​

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

op.New refuses the malformed spellings from the table above:

go
// Refused: uppercase host, default port and trailing slash each break byte equality.
op.WithIssuer("https://IDP.example.com:443/oidc/")

// Accepted: one canonical spelling.
op.WithIssuer("https://idp.example.com/oidc")

A subpath issuer works 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, followed by the mount prefix (/oidc by default). An issuer of https://login.example.com/tenant-a serves the token endpoint at /tenant-a/oidc/token.

The discovery document is the exception, and it is easy to get backwards. OpenID Connect Discovery 1.0 §4 appends the well-known suffix to the issuer, while RFC 8414 §3 inserts it between the host and the issuer path. The library serves the same document at both URLs, outside the mount prefix:

URL
Issuerhttps://login.example.com/tenant-a
Discovery document (OIDC Discovery 1.0 §4)https://login.example.com/tenant-a/.well-known/openid-configuration
Discovery document (RFC 8414 §3)https://login.example.com/.well-known/openid-configuration/tenant-a
Token endpointhttps://login.example.com/tenant-a/oidc/token

An RP that follows discovery never notices, but an operator writing proxy rules by hand must route both discovery paths. op.New fails if an endpoint override collides with the issuer-path form. 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 breaks them. Fix 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 in transit. The library catches most boot-time mistakes, but it cannot see a TLS-terminating proxy that strips the path or downcases the host downstream.

Run the RP and the OP in separate containers ​

The issuer must be https, and the browser and the RP's back-channel calls must both reach it under the same host. With the RP and the OP in separate containers, that takes a TLS-terminating proxy, a name both sides resolve, and a CA the RP trusts. This recipe uses Caddy's internal CA and op.dev.test as the host.

yaml
services:
  caddy:
    image: caddy:2
    ports: ["443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    networks:
      default:
        aliases: [op.dev.test] # the RP container resolves the issuer host to caddy
  op:
    build: ./op # listens on plain http :8080
    environment:
      ISSUER: https://op.dev.test
  rp:
    build: ./rp
    environment:
      OIDC_ISSUER: https://op.dev.test
      SSL_CERT_FILE: /certs/ca-bundle.crt
    volumes:
      - ./certs:/certs:ro
volumes:
  caddy_data:
networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/16
# Caddyfile
op.dev.test {
    tls internal
    reverse_proxy op:8080
}
  1. Resolve the host on both sides. Add 127.0.0.1 op.dev.test to the host's /etc/hosts so the browser reaches the published port 443. The network alias does the same for the RP container. Do not pick a .localhost name: those resolve to loopback (RFC 6761 §6.3), so inside the RP container the name would point back at the RP itself.

  2. Tell the OP it sits behind a proxy. Caddy forwards plain http to the OP, so the OP sees http unless the peer is trusted. List the compose subnet; Caddy's reverse_proxy sends X-Forwarded-Proto and X-Forwarded-Host by default:

    go
    op.New(
        op.WithIssuer(os.Getenv("ISSUER")), // https://op.dev.test
        op.WithTrustedProxies("172.28.0.0/16"), // the compose network
        // ... other options
    )

    Without op.WithTrustedProxies, the OP sees http and the checks that read the request scheme and host (the origin test on the login and logout pages, the redirect_uri scheme test, cookie Secure) disagree with the https issuer. The DPoP htu comparison is not among them, because the OP compares it with the advertised endpoint URL. See Client IP and WithTrustedProxies and the Go-live checklist.

  3. Trust Caddy's root CA in the RP. Once Caddy has started, copy its root certificate out with docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./certs/caddy-root.crt. Go reads SSL_CERT_FILE instead of the system bundle rather than in addition to it, so build ca-bundle.crt by appending the root to a system bundle (cat /etc/ssl/certs/ca-certificates.crt ./certs/caddy-root.crt > ./certs/ca-bundle.crt, using the bundle from the RP's own image). Without the public roots in it, the RP's calls to other HTTPS hosts fail.

  4. Trust it in the browser. caddy trust on the host installs the same root. A lost caddy_data volume generates a new CA, so repeat step 3 after one.