Skip to content

Use case — FAPI 2.0 Baseline

What is FAPI 2.0?

FAPI ("Financial-grade API") is a profile of OAuth 2.0 + OIDC maintained by the OpenID Foundation. It picks a strict subset of the underlying specs and forbids the optional flexibility that attackers historically abused. FAPI rejects RS256 signatures in favour of ES256 / PS256, requires PKCE on every authorization, mandates sender-constrained tokens (DPoP or mTLS), and makes RPs send their authorize requests through PAR + JAR rather than as plain query strings. The authorize endpoint is /auth under the configured mount prefix, so the default /oidc mount serves it at /oidc/auth. This library signs id_tokens with ES256 only, so the anti-RS256 clause is satisfied by construction.

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). FAPI 2.0 Baseline is the entry-level profile; FAPI 2.0 Message Signing adds JARM + DPoP nonce + RS-side proof signing.

This library exposes Baseline as a single profile switch (op.WithProfile(profile.FAPI2Baseline)) that flips every required flag and refuses to start in any combination that would silently violate the profile.

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() for the TLS 1.2 FAPI 1.0 RW cipher allow-list and op.LoadPublicJWKS, which strips private JWK material before client registration. TLS 1.3 deployments need their own tls.Config, because Go does not expose a TLS 1.3 cipher-suite allow-list.

What FAPI 2.0 Baseline mandates

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 auto-selects DPoP as the no-infrastructure default.
ES256 signingRFC 7518id_token_signing_alg_values_supported is ["ES256"] unconditionally; RS256 / none / HS* never advertised.
redirect_uri exact matchFAPI 2.0 §5.3No wildcards. Byte-identical comparison.
private_key_jwt client authFAPI 2.0 §3.1.3Use private_key_jwt for the token endpoint auth path. mTLS can satisfy sender constraint, but mTLS client-auth dispatch is not wired.

Architecture

FAPI 2.0 Baseline, end to end
Under FAPI 2.0 Baseline 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 tokens that come back are DPoP-bound and signed with ES256.RP / clientprivate_key_jwt + DPoPOPgo-oidc-provider1POST /parclient_assertion=<private_key_jwt> · code_challenge=S2562201 · request_uri=urn:…:<id> · expires_in3GET /authorize?request_uri=urn:…&client_id4validate against the profileES256 signing · redirect_uri exact match5the user logs in and consents, driven by the interaction endpoints6302 redirect_uri?code=…&state=…7POST /token · DPoP: <proof>code · code_verifier · client_assertion8200access_token (DPoP-bound) · id_token (ES256) · refresh_token
Steps 1 and 2 are what Baseline 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/grant"
  "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.WithGrants(grant.AuthorizationCode, grant.RefreshToken),
  op.WithProfile(profile.FAPI2Baseline), // <--- the profile switch
  op.WithFeature(feature.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"},
  }),
)

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 op.PublicClient and op.ConfidentialClient; all three implement op.ClientSeed and feed WithStaticClients(seeds ...ClientSeed).

The WithProfile call:

  1. Enables feature.PAR and feature.JAR automatically.
  2. Intersects token_endpoint_auth_methods_supported with the FAPI 2.0 §3.1.3 allow-list; configure clients with private_key_jwt for the token endpoint.
  3. Keeps id_token_signing_alg_values_supported = ["ES256"] (the OP only ever advertises and signs ES256 id_tokens; FAPI 2.0's anti-RS256 clause is satisfied by construction).
  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.

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.

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.

This is mTLS sender constraint, not token endpoint mTLS client authentication. Keep the client registered with private_key_jwt and use the forwarded certificate only to bind issued access tokens.

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:

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"]
}

The library publishes ["ES256"] for id_token_signing_alg_values_supported regardless of profile (every issued id_token is signed ES256); the FAPI 2.0 §6.2.1 mandate against RS256 is satisfied because RS256 never appears on the OP's supported set in the first place. dpop_signing_alg_values_supported covers DPoP proof acceptance and is ["ES256", "EdDSA", "PS256"].

Conformance

The OFCS fapi2-security-profile-id2-test-plan exercises this exact wiring: 48 PASSED / 9 REVIEW (manual reviewer) / 1 SKIPPED (RSA-key negative test that needs an additional client key) / 0 FAILED in the latest baseline.

For the full OFCS picture and the REVIEW / SKIPPED breakdown, see OFCS conformance status.