Skip to content

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_* or private_key_jwt.
  • FAPI-grade clients: confidential clients hardened with sender-constrained tokens (DPoP / mTLS), PAR, and asymmetric authentication.
Specs referenced on this page

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 /token from 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.

jsonc
// 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 S256 PKCE (plain is refused regardless of profile; see Design judgments #14). The code_verifier is 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 /token carries 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 declares op.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), and op.New refuses 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.

Client shape decides the authentication method
Three client shapes and the token endpoint authentication method each one ends up with: a public client authenticates with none plus PKCE, a confidential client with a client secret, and a FAPI-grade client with private_key_jwt plus a sender constraint.client shapetoken_endpoint_auth_methodPublic clientSPA · native app · CLIno secret to keepnonewith PKCE (S256) — never optionalConfidential clientbackend web app · serviceholds a secretclient_secret_basicclient_secret_postpick one and register itFAPI-grade clientsender-constrained · high assuranceFAPI 2.0 profileprivate_key_jwtplus a DPoP or mTLS sender constraint
The shape is not a label you choose — it follows from where the code runs. Anything that ships to a browser or a device cannot keep a secret, so it is a public client whatever the registration says.
MethodPre-shared secret?Asymmetric?FAPI 2.0?Typical use
none——NoPublic clients (SPA, native, CLI) with PKCE.
client_secret_basicyes—NoBackend services with a shared secret, sent as HTTP Basic. The default for confidential clients.
client_secret_postyes—NoSame secret in the form body. Discouraged for new deployments — Basic is the canonical wire format.
private_key_jwt—yesYesBackend 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.MTLSThe 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.MTLSThe 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:

  • iss and sub equal the client_id, aud is an accepted audience (see Issuer), and exp is at most 5 minutes ahead.
  • jti is required, at most 256 characters, and single-use: a replayed assertion is invalid_client.
  • For clients registered with jwks_uri, an assertion signed with an unknown kid triggers one throttled, cache-bypassing JWKS refetch before verification fails. A real key rotation recovers immediately, while random kid values cannot amplify outbound fetches.
  • If the client's JWKS cannot be fetched because of overload or a transient fault, /token answers 503 server_error, not invalid_client. A client-store outage answers 500 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:

BuilderRegistersCredential field
op.PublicClientnonenone
op.ConfidentialClientclient_secret_basic (default) or client_secret_post, set with AuthMethodSecret (hashed with Argon2id) or SecretHash
op.PrivateKeyJWTClientprivate_key_jwtJWKS, the public key set, e.g. from op.LoadPublicJWKS
go
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 ConfidentialClient using client_secret_basic or client_secret_post must have a Secret or SecretHash. A seed without one is refused, because an empty hash would accept an empty client_secret. A plaintext Secret shorter than 32 bytes is refused too; op.NewClientSecret returns a secret and its SecretHash.
  • A client record must not carry both jwks and jwks_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_jwt and 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.