Issuer
An issuer is the OP's identity URL. The same string appears in three places at once:
- the
issclaim of every token the OP signs, - the
issparameter on every authorization response (RFC 9207), - the
issuerfield 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.comis an issuer;login.example.comis not. - RPs compare it byte-for-byte.
https://login.example.comandhttps://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.WithIssueris the string every emitted token and document will carry.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 Authorization Framework
- RFC 7519 — JSON Web Token (JWT) — the
issclaim - RFC 8414 — OAuth 2.0 Authorization Server Metadata
- RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
- OpenID Connect Discovery 1.0 — §3
- OpenID Connect Core 1.0 — §2 (ID Token)
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:
| Drift | Looks like | Why it breaks |
|---|---|---|
| Trailing slash | https://idp.example.com/ vs https://idp.example.com | Discovery 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 port | https://idp.example.com:443 vs https://idp.example.com | The 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 scheme | HTTPS://idp.example.com | Some 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 host | https://Idp.Example.com | Hostnames resolve without regard to case, but an exact string comparison does not, and components that normalise the host differently disagree. |
| Non-canonical path | https://idp.example.com//oidc or .../a/../b | A component that normalises the path and one that keeps it verbatim produce different issuer strings. The library rejects these forms. |
| Fragment | https://idp.example.com#main | A fragment is never sent in an HTTP request, so it has no meaning inside iss. |
| Query string | https://idp.example.com?env=prod | Appending 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.Parseis rejected. - Must be absolute and carry an authority.
oidc.example.com(no scheme) andhttps:///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/andhttps://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.comandHtTpS://idp.example.comare 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.comandhttps://IDP.EXAMPLE.COM/oidcare rejected.u.Hostpreserves 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, andhttp://127.0.0.1:80are 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 runspath.Cleanand refuses anything that does not round-trip. - Must use
https. The one carve-out is loopback IP literals (127.0.0.0/8and[::1]), which may use plainhttpso a development boot does not need TLS. The textual hostlocalhostis not part of it, because a name resolved through DNS can be hijacked (RFC 8252 §8.3).op.WithAllowLocalhostIssuerForDev()admitslocalhostas the issuer explicitly (it requires anhttpissuer onlocalhost, such ashttp://localhost:8080), and a local passkey deployment needs it: a WebAuthn Relying Party ID must be a domain, so an issuer on127.0.0.1has no valid RP ID to pair with. Use it on a developer machine only. (localhostin redirect URIs is a separate option,op.WithAllowLocalhostRedirectURIs(); see Redirect URI.) Because the option can change the verdict, the issuer is checked inop.New's validation pass rather than insideWithIssuer, 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
provider, err := op.New(
op.WithIssuer("https://login.example.com"),
// ... other options
)op.New refuses the malformed spellings from the table above:
// 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 | |
|---|---|
| Issuer | https://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 endpoint | https://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.
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
}Resolve the host on both sides. Add
127.0.0.1 op.dev.testto the host's/etc/hostsso the browser reaches the published port 443. The network alias does the same for the RP container. Do not pick a.localhostname: those resolve to loopback (RFC 6761 §6.3), so inside the RP container the name would point back at the RP itself.Tell the OP it sits behind a proxy. Caddy forwards plain http to the OP, so the OP sees
httpunless the peer is trusted. List the compose subnet; Caddy'sreverse_proxysendsX-Forwarded-ProtoandX-Forwarded-Hostby default:goop.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 seeshttpand the checks that read the request scheme and host (the origin test on the login and logout pages, theredirect_urischeme test, cookieSecure) disagree with thehttpsissuer. The DPoPhtucomparison is not among them, because the OP compares it with the advertised endpoint URL. See Client IP andWithTrustedProxiesand the Go-live checklist.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 readsSSL_CERT_FILEinstead of the system bundle rather than in addition to it, so buildca-bundle.crtby 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.Trust it in the browser.
caddy truston the host installs the same root. A lostcaddy_datavolume generates a new CA, so repeat step 3 after one.
Read next
- Authorization Code + PKCE flow — the common flow whose
issecho (RFC 9207) depends on the canonical issuer. - Discovery — the document that carries
issuerand every endpoint URL. - Redirect URI — the other URL the OP compares byte-for-byte; the same lessons apply.
- Design judgments — Issuer identifier validation — the explicit reasoning behind the rejection set above.