Use case — FAPI 2.0 Baseline
What the profile does and what you supply
op.WithProfile(profile.FAPI2Baseline) fixes the protocol settings. The rest of a working deployment is yours.
| The profile turns on | You supply |
|---|---|
PAR, and PKCE with S256 only | Client registration: an op.PrivateKeyJWTClient with its public JWKS, or with feature.MTLS an op.TLSClientAuthClient or op.SelfSignedTLSClientAuthClient |
DPoP-bound tokens, enabled for you unless you enable feature.MTLS | TLS for the listener: op.FAPITLSConfig(), which needs an RSA server certificate for TLS 1.2 |
Client authentication limited to private_key_jwt (the two mTLS methods too, with feature.MTLS) | Behind a proxy: op.WithTrustedProxies, and for mTLS also op.WithMTLSProxy and op.WithMTLSRootCAs |
Client assertion aud accepted as the issuer string only | Stores shared by every replica (several replicas) |
| Refresh tokens that are not rotated | The resource server that checks the tokens: see Protect an API |
| Access-token lifetime of at most 10 minutes, and a PAR lifetime below 600 seconds | |
PS256, ES256 and EdDSA on client-signed input, ES256 ID tokens |
Runnable versions: examples/50-fapi-tls-jwks for the TLS and JWKS wiring, and examples/55-mtls-proxy for certificate-bound tokens behind a proxy.
Relationship to the Final Security Profile
The OpenID Foundation published the FAPI 2.0 Security Profile as a Final specification. Earlier drafts called the same profile "Baseline", and this library keeps profile.FAPI2Baseline as the name of its preset for it. op.New refuses a configuration that contradicts the preset, so these Final requirements hold without further options:
- PAR and PKCE. Authorization requests arrive through
/par, andcode_challenge_method=S256is the only accepted method. - Sender-constrained tokens. DPoP or mTLS. When neither is configured, the preset enables DPoP.
- Client authentication with
private_key_jwt, or with the two RFC 8705 §2 mTLS methods whenfeature.MTLSis on. Shared-secret methods and public clients are refused. - Client assertion
aud(§5.3.2.1(8)). The value must be the issuer identifier as a single JSON string. An array or an endpoint URL is rejected withinvalid_client. Other profiles keep accepting the issuer or the token endpoint URL. - No refresh-token rotation (§5.3.2.1(9)). A refresh response carries a new access token and no
refresh_token, and the refresh token the client holds stays redeemable until it expires or is revoked. Replay detection by rotation does not exist under this preset, while revocation, grant revocation and logout still invalidate the token.op.WithRefreshGracePeriodhas no window to set, soop.Newrejects it. - DPoP
htu. At the OP's own endpoints the proof'shtuis compared with the endpoint URL the OP advertises in discovery, or with itsmtls_endpoint_aliasesURL when one is configured. - Algorithms and lifetimes (§5.4.1, §5.3.2.2).
PS256,ES256andEdDSAon client-signed input, a PARrequest_urilifetime below 600 seconds, and server-side access-token revocation.
Beyond the Final specification, the preset also switches on JAR support, requires pre-registered exact-match redirect_uri values, signs ID tokens with ES256 only, refuses RS256 issuance, and caps access tokens at 10 minutes. Those are the library's choices and not Final requirements. A passing op.New is not a conformance claim: TLS configuration, client behaviour and the certification itself sit outside the preset.
What is FAPI 2.0?
FAPI ("Financial-grade API") is a family of OAuth 2.0 security profiles maintained by the OpenID Foundation. The FAPI 2.0 Security Profile requires PAR, PKCE with S256, sender-constrained tokens (DPoP or mTLS), confidential clients, and JWT algorithms from PS256, ES256, or EdDSA. It does not require JAR, and it is an OAuth profile: OIDC applies when the client needs an authenticated user's identifier. The authorization endpoint is /auth under the mount prefix, so the default /oidc mount serves it at /oidc/auth.
The bar exists because banking, healthcare, and government deployments need a profile that can be audited against a checklist instead of "did you remember to set every flag?". FAPI 2.0 supersedes FAPI 1.0, which is still in use. The separate profile.FAPI2MessageSigning preset adds JARM, signed request objects and signed introspection; FAPI 2.0 Message Signing lists what changes for the embedder.
This library exposes Baseline as a single profile switch (op.WithProfile(profile.FAPI2Baseline)) that enables its required checks and refuses to start in any combination that contradicts them.
A primer with each acronym (PAR, JAR, JARM, DPoP, mTLS, ES256) walked through is at FAPI 2.0 primer. This page covers the wiring.
Specs referenced on this page
Sources:
examples/03-fapi2/main.gocovers the profile flow.examples/50-fapi-tls-jwksshowsop.FAPITLSConfig()andop.LoadPublicJWKS.LoadPublicJWKSstrips private JWK material before client registration.
What the library's FAPI2Baseline enforces
| Requirement | RFC | Library behaviour |
|---|---|---|
| Pushed Authorization Requests | RFC 9126 | feature.PAR auto-enabled by the profile. request_uri returned from /par is the only authorize entry. |
| Proof Key for Code Exchange | RFC 7636 | code_challenge_method=S256 required; plain rejected. |
| Sender-constrained tokens (DPoP or mTLS) | RFC 9449 / RFC 8705 | profile.RequiredAnyOf returns {DPoP, MTLS} for this profile, so either one satisfies the requirement; if neither is configured, the constructor enables DPoP as the no-infrastructure default. |
| JWT algorithms | FAPI 2.0 §5.4.1 | The profile permits PS256, ES256, and EdDSA. This library's id_token_signing_alg_values_supported is ["ES256"] under the profile; op.New refuses RS256 together with it, and none / HS* are never advertised. |
| Client authentication | FAPI 2.0 §5.3.2.1 | private_key_jwt; with feature.MTLS, also tls_client_auth and self_signed_tls_client_auth. See Client authentication. |
Client assertion aud | FAPI 2.0 §5.3.2.1(8) | Only the issuer identifier as a JSON string. An array or an endpoint URL fails with invalid_client. |
| Refresh tokens | FAPI 2.0 §5.3.2.1(9) | Not rotated. Refresh responses carry no refresh_token; op.WithRefreshGracePeriod is rejected by op.New. |
DPoP htu | RFC 9449 §4.3 | Compared with the endpoint URL the OP advertises, or its mtls_endpoint_aliases URL. |
| Short-lived access tokens | Library policy | op.New rejects an access token lifetime above 10 minutes (profile.MaxAccessTokenTTL). |
| PAR lifetime | FAPI 2.0 §5.3.2.2 | op.New rejects op.WithPARTTL at or above 600 seconds. |
| Signed request objects | RFC 9101 | feature.JAR is auto-enabled, so request is accepted at /par and /auth but not mandatory (profile.FAPI2MessageSigning makes it so). When a request object is used, it replaces the unsigned query or form parameters instead of being merged with them. RS256 is rejected for request objects and client assertions. |
redirect_uri exact match | Library policy | The library requires a pre-registered URI and compares it byte-for-byte, without wildcards. The final FAPI 2.0 Security Profile instead requires redirect_uri in the authenticated PAR request; it does not mandate pre-registration. |
Architecture
Code (excerpts from examples/03-fapi2)
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/feature"
"github.com/libraz/go-oidc-provider/op/profile"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
const (
demoIssuer = "https://op.example.com"
demoClientID = "fapi2-example-client"
demoRedirectURI = "https://rp.example.com/callback"
)
st := inmem.New()
loginFlow := op.LoginFlow{Primary: op.PrimaryPassword{Store: st.UserPasswords()}}
provider, err := op.New(
op.WithIssuer(demoIssuer),
op.WithStore(st),
op.WithKeyset(opKeys.Keyset()),
op.WithCookieKeys(opKeys.CookieKey),
op.WithLoginFlow(loginFlow),
op.WithProfile(profile.FAPI2Baseline), // <--- the profile switch
op.WithFeature(feature.DPoP), // optional: the profile already enables DPoP
op.WithStaticClients(op.PrivateKeyJWTClient{
ID: demoClientID,
JWKS: clientJWKs, // public JWK Set as JSON bytes
RedirectURIs: []string{demoRedirectURI},
Scopes: []string{"openid", "profile", "email"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
}),
)The example keeps the in-memory user store for brevity; a real OP passes its own store.UserPasswordStore (Bring your own user store). PrivateKeyJWTClient is the typed seed for FAPI clients — it forces token_endpoint_auth_method=private_key_jwt automatically, so the embedder never has to spell that field out. The companion typed seeds are listed under Client authentication; all of them implement op.ClientSeed and feed WithStaticClients(seeds ...ClientSeed).
The paired RP in the example (rpkit.NewFAPI2) pushes the request to /par with a private_key_jwt assertion and sends a DPoP proof at /token, so the whole flow can be driven from a browser at http://127.0.0.1:9090/. The default grant set is already authorization_code plus refresh_token, so the example does not call op.WithGrants.
The library's WithProfile call:
- Enables
feature.PARandfeature.JARautomatically. - Intersects
token_endpoint_auth_methods_supportedwith the FAPI 2.0 §5.3.2.1 allow-list:private_key_jwt, plus the two mTLS methods whenfeature.MTLSis enabled. - Keeps
id_token_signing_alg_values_supported = ["ES256"](the OP advertises and signsES256ID Tokens). The profile refusesop.WithSigningAlgorithm(op.AlgRS256), because FAPI 2.0 Security Profile §5.4.1 does not permitRS256; the library has noPS256orEdDSAissuing option, although the profile permits both. - Forces
redirect_uriexact match (no wildcards anywhere). - Satisfies the DPoP-or-mTLS sender-constraint requirement by preserving an explicit
feature.MTLSopt-in when present, or by addingfeature.DPoPwhen neither binding was selected. - Pins client assertions to an issuer-only string
aud, stops refresh-token rotation, and rejectsop.WithRefreshGracePeriod.
For JAR, profile.MaxRequestObjectAge(profile.FAPI2Baseline) returns 60 * time.Minute. op.New passes that value to the request-object verifier, which applies it to the signed request object's iat age while still checking its nbf and exp claims.
Client authentication
Pick one method per client. The seed type fixes token_endpoint_auth_method, so none of them takes an auth-method field.
| Method | Seed type | Registration | Also needs |
|---|---|---|---|
private_key_jwt | op.PrivateKeyJWTClient | JWKS (public JWK Set as JSON) or JWKSURI, exactly one | nothing beyond the profile |
tls_client_auth | op.TLSClientAuthClient | exactly one of SubjectDN, SANDNS, SANURI, SANIP, SANEmail | WithFeature(feature.MTLS); a chain-verified certificate |
self_signed_tls_client_auth | op.SelfSignedTLSClientAuthClient | JWKS or JWKSURI, exactly one | WithFeature(feature.MTLS) |
- A
JWKSURIis fetched through the same SSRF-gated path as any clientjwks_uri(op.WithAllowPrivateNetwork(op.OutboundJWKS)admits a private-network host,op.WithJWKSHTTPTransportsupplies the transport). tls_client_authaccepts only a certificate whose chain was verified. Over a direct TLS connection the server must verify client certificates against its ownClientCAs. Behind a proxy that forwards the certificate in a header, the OP verifies it againstop.WithMTLSRootCAs, andop.Newfails iftls_client_authis combined withop.WithMTLSProxyand no root pool.self_signed_tls_client_authmatches the certificate's public key against the client's key set and walks no chain.- Configuring
op.WithMTLSProxyorop.WithMTLSRootCAswithoutfeature.MTLSis anop.Newerror. op.WithMTLSProxy(op.MTLSProxy{HeaderName, TrustedProxies})names the header and the proxy CIDRs allowed to set it. WhenTrustedProxiesis empty, theop.WithTrustedProxiesranges are used.- The certificate that authenticated the client also binds the access token it receives (
cnf.x5t#S256).
mTLS explains the certificate handling in detail.
mTLS instead of DPoP
The profile's default sender binding is DPoP because it needs no TLS client-certificate plumbing. If your deployment standardizes on mTLS, enable feature.MTLS explicitly and configure op.WithMTLSProxy(...) for a TLS-terminating proxy; that explicit choice suppresses the DPoP default. Sender constraint and client authentication are separate choices: a client registered with private_key_jwt can still receive certificate-bound tokens.
Decisions for a FAPI deployment
Behind a TLS-terminating proxy
DPoP needs no proxy handling. The OP compares a proof's htu with the endpoint URL it advertises, built from the issuer, the mount prefix and the endpoint path, so a proxy that rewrites the scheme or host does not change the result. The same holds for the mtls_endpoint_aliases URL when you configure one with op.WithDiscoveryMetadata.
op.WithTrustedProxies is still needed. It governs the client address that the audit trail, risk decisions and lockout use, and the scheme and host that the OP's other checks read: whether a login or logout request comes from the issuer's origin, the scheme test on redirect_uri values, and the Secure decision for cookies. Without it the OP ignores X-Forwarded-* and logs one warning. Mount on your router and Issuer show the setup. A resource server built with op/rs still compares the DPoP htu with the request it receives, because the OP does not know the API's public URL; restore the host and TLS state of the forwarded request before it reaches the verifier (Protect an API).
Server certificate
op.FAPITLSConfig() allows TLS 1.2 only with RSA-keyed AEAD suites, which follows the cipher allow-list of the FAPI 1.0 Read-Write profile that the conformance suite checks. It therefore needs an RSA server certificate. With an ECDSA certificate no TLS 1.2 suite remains and TLS 1.2 handshakes fail; TLS 1.3 is not restricted by the list and still negotiates. If your policy requires an ECDSA certificate, compose your own tls.Config instead of using the helper. When a proxy terminates TLS, the proxy's cipher policy applies and FAPITLSConfig is not involved. The OP's ES256 signing key is a separate key and unaffected.
Several replicas
Replicas share the session, grant and replay-set stores (Multi-instance deployment). The DPoP nonce source is the one extra piece, and only when nonces are switched on. profile.FAPI2Baseline does not require nonces. If you add op.WithDPoPNonceSource, replicas must share it: use Store.DPoPNonceSource from op/storeadapter/redis, because the in-memory source issues values that other replicas do not recognize. profile.FAPI2MessageSigning requires the source whenever DPoP is active.
Verifying the surface
curl -s http://localhost:8080/.well-known/openid-configuration | jq '{
pushed_authorization_request_endpoint,
request_parameter_supported,
dpop_signing_alg_values_supported,
token_endpoint_auth_methods_supported,
id_token_signing_alg_values_supported
}'Expected with the default DPoP sender binding:
{
"pushed_authorization_request_endpoint": "http://localhost:8080/oidc/par",
"request_parameter_supported": true,
"dpop_signing_alg_values_supported": ["ES256", "EdDSA", "PS256"],
"token_endpoint_auth_methods_supported": ["private_key_jwt"],
"id_token_signing_alg_values_supported": ["ES256"]
}With feature.MTLS enabled, token_endpoint_auth_methods_supported also lists tls_client_auth and self_signed_tls_client_auth. Under profile.FAPI2Baseline the library publishes ["ES256"] for id_token_signing_alg_values_supported (every issued ID Token is signed ES256); an OP without a FAPI profile publishes its own issuing algorithm, which is ["RS256"] after op.WithSigningAlgorithm(op.AlgRS256). The live FAPI 2.0 Security Profile allows PS256, ES256, and EdDSA for JWT algorithms (§5.4.1); the library's issuing choice of ES256 or RS256 is narrower, and RS256 is refused under the profile. dpop_signing_alg_values_supported covers DPoP proof acceptance and is ["ES256", "EdDSA", "PS256"].
Conformance
The library's profile.FAPI2Baseline is a policy preset, not a claim that a deployment conforms to the Final Security Profile. The differences listed under Relationship to the Final Security Profile are choices that are stricter than the specification; TLS configuration, client behaviour and certification are separate steps.
The OpenID Foundation conformance suite results for the FAPI 2.0 plans, with the suite version and run date, are on OFCS conformance status.