Overview

API security is multi-layered: TLS at the wire, mutual TLS for partner traffic, OAuth bearer tokens for delegated access, and scopes for fine-grained authorisation. None of these layers is sufficient on its own. They compose — and the FAPI 2.0 profile (Financial-grade API) is the regulator’s answer for how they should compose.

This article covers the patterns that hold up in production: which OAuth flow to use when, how to validate a JWT correctly, how to deploy mTLS without an operational nightmare, and what FAPI 2.0 actually requires.

Don’t roll your own

The single largest source of API security incidents in financial services is custom auth code. Use a battle-tested IdP (Keycloak, Ping, ForgeRock, Okta) and a battle-tested client library (org.springframework.security, passport, auth0). Custom JWT verification, custom session encryption, custom signature checking — all consistently fail security review.

Defence layers

OAuth flows

OAuth 2.1 (the IETF consolidation, dropping deprecated grants) leaves four flows in active use. Pick by client type, not by familiarity.

FlowClientToken typeWhen to use
Authorization code + PKCESPA, mobile, web app with backendAccess + refresh + IDUser-facing apps
Client credentialsServer-to-server, no userAccessService-to-service, batch jobs
Device codeTVs, kiosks, no keyboardAccess + refreshATM apps, branch tablets without password input
Token exchange (RFC 8693)Service A acting on behalf of userAccessMicroservice chains under user identity

Implicit, password, and client credentials with shared secret in URL are deprecated. If a vendor or framework wants you to use them in 2026, push back.

Client credentials

The simplest server-to-server flow. Service A authenticates to the AS with its client credentials, gets an access token, calls Service B.

get-service-token.shbash
# client_secret_basic: id:secret in HTTP Basic; bytes don’t leak in logs
curl -sS https://idp.acme-bank.com/realms/prod/protocol/openid-connect/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  -d "scope=accounts.read" \
  -d "audience=accounts-api"

For higher assurance, replace client_secret_basic with private_key_jwt: the client signs an assertion JWT with its private key. The AS verifies via the registered public key. No shared secret leaves the client — this is the FAPI 2.0 default.

Authorization code + PKCE

The user-facing flow. The browser redirects to the AS, the user authenticates, the AS redirects back with a short-lived code, the client exchanges the code (plus a PKCE verifier) for tokens.

auth-redirect.txthttp
# 1. Browser redirect to AS
GET /authorize?
  response_type=code
  &client_id=acme-mobile-app
  &redirect_uri=https://app.acme-bank.com/callback
  &scope=openid accounts.read payments.write
  &state=7fd92e1...             # CSRF protection
  &code_challenge=E9Melh...     # SHA-256 of verifier
  &code_challenge_method=S256
  &nonce=a3b1c...               # OIDC replay protection

# 2. After auth, AS redirects back
HTTP/1.1 302 Found
Location: https://app.acme-bank.com/callback?code=eyJhbGc...&state=7fd92e1...

# 3. Client exchanges code + verifier for tokens
POST /token
  grant_type=authorization_code
  &client_id=acme-mobile-app
  &code=eyJhbGc...
  &redirect_uri=https://app.acme-bank.com/callback
  &code_verifier=dBjftJeZ...     # original random string

PKCE is mandatory in OAuth 2.1, even for confidential clients. code_challenge_method=S256 only; plain is unsafe.

mTLS

Mutual TLS authenticates both sides at the transport layer. The bank presents a server certificate as usual; the partner presents a client certificate signed by a CA the bank trusts.

  • For external partners: mTLS is the contract. No client cert → no connection. The cert is the credential.
  • For internal east-west traffic: mTLS via service mesh (Istio, Linkerd) is the modern default. Operations run mTLS for everything; developers don’t see it.
  • For OAuth client authentication: tls_client_auth uses the same client cert to authenticate the OAuth client at the token endpoint — no shared secret needed.
nginx-mtls.confnginx
server {
    listen 443 ssl;
    server_name api.acme-bank.com;

    ssl_certificate           /etc/ssl/server.crt;
    ssl_certificate_key       /etc/ssl/server.key;
    ssl_protocols             TLSv1.3;

    # Trust partner CA bundle
    ssl_client_certificate    /etc/ssl/partners-ca-bundle.crt;
    ssl_verify_client         on;
    ssl_verify_depth          2;

    # Pass cert details to backend for binding token to cert
    proxy_set_header X-Client-Cert-DN  $ssl_client_s_dn;
    proxy_set_header X-Client-Cert-Fp  $ssl_client_fingerprint;
    proxy_set_header X-Client-Verify   $ssl_client_verify;

    location / { proxy_pass http://api-backend; }
}
Cert lifecycle is the operational pain

mTLS works perfectly until a cert expires at 2am on a bank holiday. Build automated rotation (cert-manager, Vault PKI), alert on expiry > 30 days out, and require automation for partner cert rotation, not email exchanges. The mTLS protocol is not the problem; cert lifecycle is.

JWT validation

A JWT validation that fails open is worse than no validation. The check must include all of:

  1. Signature verification against the AS’s JWKS (key cached, refreshed on key rotation).
  2. Algorithm pinning — never trust the alg in the header. Pin to RS256 or ES256; reject none and HS256 (which would let an attacker forge with the public key).
  3. Issuer claim matches the expected iss.
  4. Audience claim contains your service identifier.
  5. Not-before (nbf) and expiry (exp) windows are valid with a small clock skew tolerance (60s).
  6. Required scopes/roles for the called endpoint are present.
JwtValidator.javajava
public JwtClaimsSet validate(String token) {
  SignedJWT jwt = SignedJWT.parse(token);

  // 1. Algorithm pinning — reject anything else
  if (!JWSAlgorithm.RS256.equals(jwt.getHeader().getAlgorithm())) {
    throw new SecurityException("unsupported alg");
  }

  // 2. Signature against JWKS (cached, with rotation)
  JWK key = jwks.findByKid(jwt.getHeader().getKeyID())
      .orElseThrow(() -> new SecurityException("unknown kid"));
  if (!jwt.verify(new RSASSAVerifier((RSAKey) key))) {
    throw new SecurityException("bad signature");
  }

  // 3, 4, 5. iss, aud, exp, nbf with 60s skew
  JwtClaimsSet c = jwt.getJWTClaimsSet();
  if (!ISSUER.equals(c.getIssuer())) throw new SecurityException("iss");
  if (!c.getAudience().contains(AUDIENCE)) throw new SecurityException("aud");
  long now = Instant.now().getEpochSecond();
  if (now > c.getExpirationTime().getTime() / 1000 + 60) throw new SecurityException("exp");
  if (c.getNotBeforeTime() != null
        && now + 60 < c.getNotBeforeTime().getTime() / 1000) {
    throw new SecurityException("nbf");
  }

  return c;
}

FAPI 2.0

FAPI (Financial-grade API) is the OpenID Foundation profile that hardens OAuth and OIDC for financial services. FAPI 2.0 is what regulators in the UK, Australia, Brazil, and Saudi Arabia (Open Banking) reference.

Key requirements:

  • PAR (Pushed Authorization Requests). Authorization parameters POSTed to the AS over a back channel before the redirect — eliminates parameter tampering in the front channel.
  • JAR (JWT-Secured Authorization Requests). The authorization request is itself a signed JWT.
  • Sender-constrained tokens. Tokens are bound to the client’s mTLS cert (RFC 8705) or to a DPoP key. A stolen token is useless without the binding.
  • Private key JWT for client authentication (no shared secret).
  • PS256 / ES256 algorithms only; no RS256 in FAPI 2.0.

Scope design

Scopes are coarse-grained. Use them to express “this client can read accounts”, not “this client can read account 12345”. Per-resource authorisation belongs in the application, not in the scope.

  • Two-part scopes: resource.actionaccounts.read, payments.write, kyc.admin. Predictable and parseable.
  • No * scopes in production clients. A client requesting all scopes is a credential theft amplifier.
  • One scope per endpoint group. Don’t require five scopes for one call.
  • Per-claim authorisation. “The token holds a list of authorised account IDs” is a claim, not a scope. Use aud/resource claims for FAPI-style fine-grained delegation.

Common pitfalls

Algorithm confusion

Verifying a JWT with the algorithm from the header is the canonical CVE in JWT libraries. Pin algorithms server-side; never trust client-provided alg. none and HS256 against an RSA public key are the two well-known exploits.

Long-lived bearer tokens

Access tokens with 24-hour TTL turn token theft into a one-day breach. Keep access tokens short (5–15 minutes); use refresh tokens with rotation for long sessions; use sender-constrained binding for high-value APIs.

JWKS without caching or rotation handling

Refetching JWKS on every request hammers the IdP. Caching forever means a key rotation breaks production. Cache for 10 minutes, refresh on unknown kid, alert on cache misses.

Storing tokens in localStorage

SPAs that store access tokens in localStorage expose them to any XSS. Use the BFF (Backend-For-Frontend) pattern: keep tokens server-side in an HTTP-only cookie session; the browser never sees the token.

Production checklist

  1. TLS 1.3 only on external endpoints. Disable TLS 1.0/1.1; alert on TLS 1.2 usage trend.
  2. HSTS with preload on all consumer-facing endpoints.
  3. OAuth 2.1, PKCE mandatory, no implicit/password grants.
  4. Private key JWT or mTLS for confidential client auth.
  5. Sender-constrained tokens for high-value APIs (mTLS or DPoP).
  6. Algorithm pinning in JWT validation.
  7. JWKS caching with rotation handling.
  8. Short access token TTL; refresh rotation.
  9. Token revocation endpoint wired and tested.
  10. Per-endpoint scope enforcement.
  11. Rate limiting at the gateway by client_id, not just IP.
  12. Cert lifecycle automation; no manual partner cert email exchanges.