Skip to content

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 onYou supply
PAR, and PKCE with S256 onlyClient 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.MTLSTLS 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 onlyStores shared by every replica (several replicas)
Refresh tokens that are not rotatedThe 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, and code_challenge_method=S256 is 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 when feature.MTLS is 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 with invalid_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.WithRefreshGracePeriod has no window to set, so op.New rejects it.
  • DPoP htu. At the OP's own endpoints the proof's htu is compared with the endpoint URL the OP advertises in discovery, or with its mtls_endpoint_aliases URL when one is configured.
  • Algorithms and lifetimes (§5.4.1, §5.3.2.2). PS256, ES256 and EdDSA on client-signed input, a PAR request_uri lifetime 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.go covers the profile flow. examples/50-fapi-tls-jwks shows op.FAPITLSConfig() and op.LoadPublicJWKS. LoadPublicJWKS strips private JWK material before client registration.

What the library's FAPI2Baseline enforces ​

RequirementRFCLibrary behaviour
Pushed Authorization RequestsRFC 9126feature.PAR auto-enabled by the profile. request_uri returned from /par is the only authorize entry.
Proof Key for Code ExchangeRFC 7636code_challenge_method=S256 required; plain rejected.
Sender-constrained tokens (DPoP or mTLS)RFC 9449 / RFC 8705profile.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 algorithmsFAPI 2.0 §5.4.1The 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 authenticationFAPI 2.0 §5.3.2.1private_key_jwt; with feature.MTLS, also tls_client_auth and self_signed_tls_client_auth. See Client authentication.
Client assertion audFAPI 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 tokensFAPI 2.0 §5.3.2.1(9)Not rotated. Refresh responses carry no refresh_token; op.WithRefreshGracePeriod is rejected by op.New.
DPoP htuRFC 9449 §4.3Compared with the endpoint URL the OP advertises, or its mtls_endpoint_aliases URL.
Short-lived access tokensLibrary policyop.New rejects an access token lifetime above 10 minutes (profile.MaxAccessTokenTTL).
PAR lifetimeFAPI 2.0 §5.3.2.2op.New rejects op.WithPARTTL at or above 600 seconds.
Signed request objectsRFC 9101feature.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 matchLibrary policyThe 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 ​

The library's FAPI2Baseline path, end to end
With the library's FAPI2Baseline profile, the RP pushes its authorization request to the pushed-authorization endpoint and receives a request_uri; the browser only ever carries that reference. The token request is authenticated with private_key_jwt and carries a DPoP proof, and the returned access token is DPoP-bound while the library's ID Token is signed with ES256.RP / clientprivate_key_jwtOPgo-oidc-provider1POST /parclient_assertion · code_challenge=S2562201 · request_uri=urn:…:<id> · expires_inGET /auth?request_uri=urn:…&client_idvalidate against the profileredirect_uri exact matchlibrary ID Token: ES256the interaction endpoints obtain an authorization decision302 redirect_uri?code=…&state=…POST /token · DPoP: <proof>code · code_verifier · client_assertion200access_token (DPoP-bound) · id_token · refresh_token
Steps 1 and 2 are what the library's profile adds up front. After them the browser carries a reference rather than the request itself, so nothing in the URL bar is worth tampering with.

Code (excerpts from examples/03-fapi2) ​

go
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:

  1. Enables feature.PAR and feature.JAR automatically.
  2. Intersects token_endpoint_auth_methods_supported with the FAPI 2.0 §5.3.2.1 allow-list: private_key_jwt, plus the two mTLS methods when feature.MTLS is enabled.
  3. Keeps id_token_signing_alg_values_supported = ["ES256"] (the OP advertises and signs ES256 ID Tokens). The profile refuses op.WithSigningAlgorithm(op.AlgRS256), because FAPI 2.0 Security Profile §5.4.1 does not permit RS256; the library has no PS256 or EdDSA issuing option, although the profile permits both.
  4. Forces redirect_uri exact match (no wildcards anywhere).
  5. Satisfies the DPoP-or-mTLS sender-constraint requirement by preserving an explicit feature.MTLS opt-in when present, or by adding feature.DPoP when neither binding was selected.
  6. Pins client assertions to an issuer-only string aud, stops refresh-token rotation, and rejects op.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.

MethodSeed typeRegistrationAlso needs
private_key_jwtop.PrivateKeyJWTClientJWKS (public JWK Set as JSON) or JWKSURI, exactly onenothing beyond the profile
tls_client_authop.TLSClientAuthClientexactly one of SubjectDN, SANDNS, SANURI, SANIP, SANEmailWithFeature(feature.MTLS); a chain-verified certificate
self_signed_tls_client_authop.SelfSignedTLSClientAuthClientJWKS or JWKSURI, exactly oneWithFeature(feature.MTLS)
  • A JWKSURI is fetched through the same SSRF-gated path as any client jwks_uri (op.WithAllowPrivateNetwork(op.OutboundJWKS) admits a private-network host, op.WithJWKSHTTPTransport supplies the transport).
  • tls_client_auth accepts only a certificate whose chain was verified. Over a direct TLS connection the server must verify client certificates against its own ClientCAs. Behind a proxy that forwards the certificate in a header, the OP verifies it against op.WithMTLSRootCAs, and op.New fails if tls_client_auth is combined with op.WithMTLSProxy and no root pool.
  • self_signed_tls_client_auth matches the certificate's public key against the client's key set and walks no chain.
  • Configuring op.WithMTLSProxy or op.WithMTLSRootCAs without feature.MTLS is an op.New error.
  • op.WithMTLSProxy(op.MTLSProxy{HeaderName, TrustedProxies}) names the header and the proxy CIDRs allowed to set it. When TrustedProxies is empty, the op.WithTrustedProxies ranges 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 ​

sh
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:

json
{
  "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.