Use case — public / internal scopes
What is a "scope"?
In OAuth 2.0 (RFC 6749 §3.3), a scope is a string the client adds to its /auth request to say what kind of access it wants — for example email, profile, billing.read. The OP decides which scopes to grant; the access token carries the granted scopes; the resource server inspects them to authorise API calls.
OIDC Core 1.0 §3.1.2.1 uses openid to identify an OIDC request, while §5.4 maps profile, email, address, and phone to user claims. Additional scopes can be defined for your deployment; this page shows how the library separates a public catalog (advertised and shown at consent) from an internal one (usable by allowed clients but omitted from discovery).
Specs referenced on this page
- RFC 6749 — OAuth 2.0 Authorization Framework, §3.3 (scope)
- OpenID Connect Core 1.0 — §5.4 (
profile,email, ... mapped to claim sets) - OpenID Connect Discovery 1.0 —
scopes_supported
Vocabulary refresher
- Scope vs claim — A scope is a string the client requests at
/authto say what kind of access it wants (e.g.email,billing.read). A claim is a key/value piece of identity that ends up in theid_tokenor/userinforesponse (e.g.email,email_verified). Scopes are coarse — one scope authorises a bundle of claims. For per-claim control see Claims request. scopes_supported— A discovery field listing the scopes the OP advertises. RPs read it to know what they can request. Scopes that don't appear here can still be issued —scopes_supportedis advertisement, not authorisation.
The split
op.PublicScope(name, label) and op.InternalScope(name) are constructors that return a Scope value. Pass each to op.WithScope:
op.WithScope(op.PublicScope("billing.read", "Read your billing data")),
op.WithScope(op.InternalScope("internal:audit")),PublicScope:
- Surfaces in
scopes_supportedof the discovery document. - Issues to any registered RP that lists it in its seed's
Scopes.
InternalScope:
- Not in
scopes_supported. - Acceptance is governed by
Scope.AllowedClients: an empty list means any RP may request it; a non-empty list scopes acceptance to the named clients (any other client requesting the scope is rejected withinvalid_scopeper RFC 6749 §5.2). op.Newrejects anInternalScopewhose name collides with an OIDC standard scope, so the discovery document never violates OIDC Discovery 1.0 §3.
Visibility means discovery and nothing else. Whether a scope reaches the consent screen does not depend on Public: when the user is asked for consent, the screen lists every requested scope, public or internal. The built-in screen prints each scope's Name and, if set, its Description. PublicScope(name, label) stores the label as Scope.Title, which the consent prompt does not receive, so give a scope a user-facing sentence with the struct form (op.Scope{Name: ..., Description: ..., Public: true}, shown below). A custom consent UI receives the same Name, Description and Required fields.
Releasing custom claims via Scope.Claims
A custom scope can declare which /userinfo claim names it unlocks by populating the Scope.Claims slice. When the scope is granted, the OP releases those claim names from the /userinfo response alongside the standard OIDC mappings:
op.WithScope(op.Scope{
Name: "billing.read",
Title: "Billing",
Description: "Read your billing history",
Claims: []string{"billing_tier", "billing_account_id"},
Public: true,
}),When a client is granted billing.read, the /userinfo response surfaces billing_tier and billing_account_id (in addition to whatever profile, email, etc. release) — provided your UserStore (or the claim resolver wired into the interaction driver) actually carries those claim names for the subject.
Custom scopes release declared claims
The configured scope catalogue is projected onto the runtime scope → claim-name map. When a custom scope is granted, /userinfo automatically releases the claim names declared on Scope.Claims, subject to the subject's available claim data.
Standard OIDC scopes are unaffected
profile, email, address, phone keep their RFC-defined claim mappings (OIDC Core §5.4). Scope.Claims is the custom-scope hook — overriding a standard scope's claims is not necessary in normal usage, and unsetting Public: true on the standard scopes is rejected at op.New.
OIDC standard scopes
openid, profile, email, address, phone, and offline_access are auto-registered with built-in defaults; you don't need to declare them. The example focuses on your scope catalogue.
Put a scope's claims in the ID Token
By default a scope's claims are returned from /userinfo only. OIDC Core 1.0 §5.4 says that when a request asks for an access token, the claims of profile, email, address and phone come from the UserInfo endpoint, and the ID Token stays small. op.WithIDTokenScopeClaims is a deliberate deviation from that rule. Name the scopes whose claims should also be written into the ID Token:
op.WithScope(op.Scope{
Name: "billing.read",
Claims: []string{"billing_tier"},
Public: true,
}),
op.WithIDTokenScopeClaims("profile", "email", "billing.read"),- Applies to the
authorization_code,refresh_token,device_codeand CIBA grants. A custom grant's handler owns its ID Token body and is not affected. - A claim is added when the response's scope contains the scope that releases it. On a refresh, that is the scope the request narrowed to, not the scope originally granted.
- A claim the user record does not hold is left out. With no
UserStore, nothing is added. - It combines with the
claimsrequest parameter. A claim named there is decided by that request'svalue/valuesconstraints, so a constraint that is not met keeps the claim out even when a listed scope releases it. profileandaddressadd many claims; the ID Token grows with them. List only what the client reads from the token.
op.New rejects a configuration where a name is not profile, email, address, phone or a registered custom scope with a non-empty Claims; where the name is openid, offline_access or empty; or where the call has no arguments or happens twice. It also rejects a custom scope whose Claims include a claim the ID Token reserves: iss, sub, aud, iat, exp, auth_time, nonce, acr, amr, azp, at_hash, c_hash, sid. Failing at construction, rather than dropping the claim at issuance, means a misconfiguration shows up at startup. For the audience problem this solves, see Calling the API and /userinfo from the same SPA.
Per-client allow-list
Add the scope to the client's Scopes:
op.WithStaticClients(
op.PublicClient{
ID: "billing-app",
RedirectURIs: []string{"https://billing.example.com/callback"},
Scopes: []string{"openid", "billing.read"},
},
op.PublicClient{
ID: "audit-dashboard",
RedirectURIs: []string{"https://audit.example.com/callback"},
Scopes: []string{"openid", "internal:audit"},
},
)A client requesting a scope it doesn't list gets invalid_scope — the catalogue and the per-client list are ANDed. Internal scopes add a second AND with Scope.AllowedClients: a client absent from that list is rejected even if it lists the scope on its own seed.
Verifying
curl -s http://localhost:8080/.well-known/openid-configuration | jq .scopes_supported
# [
# "openid", "profile", "email", "address", "phone", "offline_access",
# "billing.read"
# ]internal:audit is absent — and that's the point.
When to use it
| Scenario | Pick |
|---|---|
| Internal admin app on the same OP, want it hidden from user-facing discovery | InternalScope |
| Beta scope you're rolling out to a small allow-list | InternalScope + populate the scope's AllowedClients |
| Public API marketplace where every scope is shoppable | All PublicScope |