Client types
A client is the application that asks the OP for tokens. Clients fall into two structural buckets, public (cannot keep a secret) and confidential (can), and FAPI 2.0 deployments raise the bar further on top of confidential. The bucket decides how the client authenticates at the token endpoint, and that choice in turn affects refresh-token policy, sender-constraint requirements, and whether the consent prompt can be skipped.
Mental model in 30 seconds
- Public clients: SPAs, native apps, mobile apps. The code ships to users, so a secret in it is not secret. They use
none+ PKCE. - Confidential clients: backends, services, machine-to-machine callers. They can hold a secret or a private key and use
client_secret_*orprivate_key_jwt. - FAPI-grade clients: confidential clients hardened with sender-constrained tokens (DPoP / mTLS), PAR, and asymmetric authentication.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 §2.1 (client types), §2.3 (client authentication)
- RFC 7521 / RFC 7523 — Assertion-based client authentication
- RFC 7636 — PKCE
- RFC 8252 — OAuth 2.0 for Native Apps
- RFC 8705 — Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 9449 — DPoP
- OpenID Connect Core 1.0 — §9 (client authentication)
- FAPI 2.0 Security Profile — §5.3.2.1 (permitted client authentication methods)
Public clients
Public clients are applications whose code runs on a device the user controls: single-page apps, mobile apps, native desktop apps, and CLIs. Anything shipped with the binary or page is in the attacker's hands too, so there is nowhere to hide a long-lived secret.
When to use one
Use a public client for apps that log a human in on the user's device.
- A browser SPA redirects to
/auth, then exchanges the code and PKCE verifier at/tokenfrom JavaScript. - A mobile or desktop app receives the authorization code through a custom URI scheme, claimed
https, or loopback redirect. - A CLI opens a local loopback listener, sends the user through browser login, and receives the authorization code locally.
Use a confidential client for server-side RPs and service-to-service callers where a backend can keep a shared secret, private key, or certificate out of user-reachable storage.
Why the type exists
The split exists because a credential only authenticates a client if the client can keep it secret. If an SPA or mobile app had to use a client_secret, the developer could only put it in a JavaScript bundle, an app binary, or a local config file. Every user, attackers included, would receive that value, so the protocol cannot treat it as a secret.
// Wrong: the secret has to ship inside the SPA bundle, so it is not a secret
{
"client_id": "browser-spa",
"token_endpoint_auth_method": "client_secret_basic",
"client_secret": "shipped-in-javascript"
}
// Right: register a public client and protect it with code + PKCE
{
"client_id": "browser-spa",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"redirect_uris": ["https://app.example.com/callback"]
}OAuth / OIDC therefore names public clients explicitly and protects them with other mechanisms: a registered client_id, exact redirect-URI matching, per-request PKCE, refresh-token rotation, and DPoP where appropriate. A public client is not an "untrusted client"; it is a client protected differently because it cannot keep a secret.
The library treats a client as public when its TokenEndpointAuthMethod is "none". These protections compensate for the missing secret:
- PKCE is required. The OP accepts only
S256PKCE (plainis refused regardless of profile; see Design judgments #14). Thecode_verifieris the per-request secret that proves "the same client that started the flow is the one finishing it"; without it, an authorization code intercepted on the redirect is enough to mint tokens. - Refresh tokens are rotated and DPoP-bound. Every successful refresh exchange retires the previous refresh token and binds the next one to the DPoP key the client presented. A leaked refresh on a public client cannot be reused without the matching key, and a reuse of an already-rotated token revokes the entire chain. See Design judgments #15.
- Loopback redirects are admitted. RFC 8252 §7.3 lets native clients vary the port on
127.0.0.1/[::1]so a CLI can use an OS-assigned ephemeral port; see Redirect URI.
Public clients still receive ID Tokens and refresh tokens and can call /userinfo. They are full RPs that work under stricter rules.
Confidential clients
Confidential clients are backend services that keep a secret or a private key out of user-reachable storage: a server-side environment variable, a secrets manager, an HSM. They register a TokenEndpointAuthMethod other than "none".
A confidential client gets:
- Authentication at the token endpoint. Every
POST /tokencarries credentials; the OP verifies them before issuing anything. The verifier is one of the methods listed below. - PKCE on the code flow. The default profile,
profile.OAuth21, requires PKCE from confidential clients too. It is optional for them only when the deployment declaresop.WithProfile(profile.OIDCCore)for legacy relying parties; see Declaring a security profile. - Looser refresh-token policy. Refresh tokens are rotated like every other client (except under the two FAPI 2.0 profiles, which do not rotate), but the chain is not DPoP-bound by default — the access tokens minted on each refresh are still bound to whatever key was presented, but the refresh chain itself can rotate keys. See Design judgments #15.
- Eligibility for first-party auto-consent.
op.WithFirstPartyClients(...)lets the consent prompt be skipped for vetted internal clients. Only statically seeded and admin-provisioned clients are eligible (DCR-registered clients are excluded by design), andop.Newrefuses the option together with a FAPI 2.0 profile.
FAPI-grade clients
The FAPI 2.0 Security Profile requires confidential clients, PAR, sender-constrained access tokens (DPoP or mTLS), and either mTLS client authentication or private_key_jwt. It does not require a signed request object. This library's profile.FAPI2Baseline additionally requires JAR and admits only private_key_jwt for token-endpoint client authentication, plus the two mTLS methods when feature.MTLS is enabled (see below). See the FAPI 2.0 primer.
The protocol has no separate "FAPI client type": a FAPI-grade client is a confidential client that meets the extra requirements, and the library enforces them at construction and at request time.
Authentication methods
The library names six methods from the IANA "OAuth Token Endpoint Authentication Methods" registry as op.AuthMethod constants. Four can be used everywhere; the two mTLS client-authentication methods need feature.MTLS.
The figure traces each client shape to the token_endpoint_auth_method it registers: a public client lands on none, a confidential client on a client_secret_* method, and a FAPI-grade client on an asymmetric method.
| Method | Pre-shared secret? | Asymmetric? | FAPI 2.0? | Typical use |
|---|---|---|---|---|
none | — | — | No | Public clients (SPA, native, CLI) with PKCE. |
client_secret_basic | yes | — | No | Backend services with a shared secret, sent as HTTP Basic. The default for confidential clients. |
client_secret_post | yes | — | No | Same secret in the form body. Discouraged for new deployments — Basic is the canonical wire format. |
private_key_jwt | — | yes | Yes | Backend signs a short-lived JWT assertion with its own private key; OP verifies against the registered JWKS. The method this library uses for its FAPI profile. |
tls_client_auth | — | yes (PKI) | Yes, with feature.MTLS | The client authenticates with a certificate whose chain the OP verified, matched against one registered subject DN or SAN. Seed it with op.TLSClientAuthClient. |
self_signed_tls_client_auth | — | yes (pinned) | Yes, with feature.MTLS | The certificate's public key must equal a key in the client's JWKS. Seed it with op.SelfSignedTLSClientAuthClient. |
Without feature.MTLS, discovery does not list either method, so enable the feature for any client that uses one. mTLS describes both methods.
mTLS-bound access tokens (RFC 8705 §3) are a separate feature and do not need either method: with feature.MTLS enabled, tokens issued to a client that presented a certificate carry cnf.x5t#S256 whatever method the client authenticated with.
client_secret_jwt (HS256-shared-secret JWT) is not implemented. A shared-secret JWT widens the damage of a leaked secret and offers nothing private_key_jwt does not.
private_key_jwt assertions are checked strictly:
issandsubequal theclient_id,audis an accepted audience (see Issuer), andexpis at most 5 minutes ahead.jtiis required, at most 256 characters, and single-use: a replayed assertion isinvalid_client.- For clients registered with
jwks_uri, an assertion signed with an unknownkidtriggers one throttled, cache-bypassing JWKS refetch before verification fails. A real key rotation recovers immediately, while randomkidvalues cannot amplify outbound fetches. - If the client's JWKS cannot be fetched because of overload or a transient fault,
/tokenanswers503 server_error, notinvalid_client. A client-store outage answers500 server_error. The client can retry in both cases.
Pick one method per client and stick to it
The discovery document lists every method the OP is configured to accept, but each client registers exactly one. Mixing methods on a single client is not standardised and has been the source of downgrade-attack research papers — the library matches the registered method exactly and refuses fallbacks.
Provisioning options
A client and its TokenEndpointAuthMethod reach the OP in one of two ways.
Static seeds go through op.WithStaticClients(seeds ...ClientSeed). A seed is one of three typed builders, each fixing the authentication method for you:
| Builder | Registers | Credential field |
|---|---|---|
op.PublicClient | none | none |
op.ConfidentialClient | client_secret_basic (default) or client_secret_post, set with AuthMethod | Secret (hashed with Argon2id) or SecretHash |
op.PrivateKeyJWTClient | private_key_jwt | JWKS, the public key set, e.g. from op.LoadPublicJWKS |
jwks, err := op.LoadPublicJWKS("keys/fapi-client.jwks.json")
if err != nil {
return err
}
provider, err := op.New(
// ...
op.WithStaticClients(
op.PublicClient{
ID: "web-spa",
RedirectURIs: []string{"https://app.example.com/callback"},
Scopes: []string{"openid", "profile"},
},
op.ConfidentialClient{
ID: "billing-backend",
Secret: os.Getenv("BILLING_CLIENT_SECRET"),
RedirectURIs: []string{"https://billing.example.com/callback"},
Scopes: []string{"openid"},
},
op.PrivateKeyJWTClient{
ID: "bank-connector",
JWKS: jwks,
RedirectURIs: []string{"https://bank.example.com/callback"},
Scopes: []string{"openid"},
},
),
)Every seed is validated at construction and op.New refuses to start on a malformed one. Two rules matter most:
- A
ConfidentialClientusingclient_secret_basicorclient_secret_postmust have aSecretorSecretHash. A seed without one is refused, because an empty hash would accept an emptyclient_secret. A plaintextSecretshorter than 32 bytes is refused too;op.NewClientSecretreturns a secret and itsSecretHash. - A client record must not carry both
jwksandjwks_uri(RFC 7591 §2). Dynamic registration rejects the pair, and a record that holds both (possible in a bring-your-own store) is refused on request-object verification,private_key_jwtand response encryption alike.
All three builders also take DefaultMaxAge (*int64, seconds), RequireAuthTime and DefaultACRValues, with the same meaning as the default_max_age, require_auth_time and default_acr_values registration metadata. DefaultMaxAge applies when the request omits max_age, and DefaultACRValues when it omits acr_values. op.New refuses a negative DefaultMaxAge and a default acr value the OP does not advertise.
Static seeds suit a known fleet: internal tools, FAPI deployments, single-tenant SaaS.
Dynamic Client Registration (RFC 7591) goes through op.WithDynamicRegistration(...). RPs register themselves at runtime with POST /register. The OP applies the same validation, hashes the generated client_secret at rest, returns the plaintext exactly once (see Design judgments #20), and exposes the RFC 7592 management endpoints. It suits marketplaces, multi-tenant SaaS, and any deployment whose operator does not know the clients in advance.
The two paths coexist on one OP; a static FAPI fleet next to dynamic partner clients is a common shape.
Public clients are still registered
"Public client" does not mean "anyone can show up". The client_id was issued by some path, static seed or DCR, and the OP refuses requests that name an unknown client_id. PKCE-only authentication applies to a client_id the OP already knows; it is not a "no registration" mode.
Read next
- Scopes and claims — what a client can ask for once it has authenticated.
- Sender constraint — DPoP and mTLS bind the access token to a key the client holds; the FAPI-grade requirement.
- Dynamic Client Registration — the runtime self-registration path and its full lifecycle.
- FAPI 2.0 primer — the profile that pins everything together for high-assurance deployments.