Skip to content

Architecture

The BFF sits between the SPA and all backend services. It handles all OIDC interactions and proxies API calls with Bearer token injection. The SPA never communicates directly with the identity provider or backend APIs.

graph LR
    SPA["SPA<br/>(React / Vue / Angular)"]
    BFF["BFF Server<br/>(Granit.Bff + YARP)"]
    IDP["Identity Provider<br/>(OpenIddict / Keycloak)"]
    API1["API Service 1"]
    API2["API Service 2"]
    REDIS["Redis<br/>(Session Store)"]

    SPA -->|"Cookie<br/>(__Host-granit-bff)"| BFF
    BFF -->|"OIDC<br/>(Authorization Code + PKCE)"| IDP
    BFF -->|"Bearer token<br/>(injected by YARP)"| API1
    BFF -->|"Bearer token<br/>(injected by YARP)"| API2
    BFF -->|"Store/retrieve<br/>tokens"| REDIS

    style BFF fill:#4a9eff,color:#fff,stroke-width:2px
    style REDIS fill:#dc382d,color:#fff
    style IDP fill:#f5a623,color:#fff
ComponentResponsibilityPackage
GranitBffModuleRegisters IBffTokenStore, IBffCsrfTokenGenerator, BffMetrics, BffActivitySourceGranit.Bff
GranitBffOptionsCentral configuration: authority, client credentials, session duration, cookie nameGranit.Bff
IBffTokenStoreStore, retrieve, and remove BffTokenSet by session ID in IDistributedCacheGranit.Bff
IBffCsrfTokenGeneratorGenerate and validate HMAC-SHA256 CSRF tokens bound to sessionsGranit.Bff
BffLoginEndpointsOIDC authorization code flow with PKCE: /bff/login + /bff/callbackGranit.Bff.Endpoints
BffLogoutEndpointsSession cleanup + RP-Initiated Logout: /bff/logoutGranit.Bff.Endpoints
BffUserEndpointsFiltered user claims for the SPA: /bff/userGranit.Bff.Endpoints
BffCsrfEndpointsCSRF token generation: /bff/csrf-tokenGranit.Bff.Endpoints
BffSecurityHeadersMiddlewareX-Frame-Options: DENY + CSP frame-ancestors 'none' on login/callbackGranit.Bff.Endpoints
BffTokenInjectionTransformYARP transform: load tokens, silent refresh, inject Authorization: BearerGranit.Bff.Yarp
BffCsrfValidationTransformYARP transform: validate X-CSRF-Token on POST/PUT/DELETE/PATCHGranit.Bff.Yarp

The login flow implements the full OAuth 2.0 Authorization Code flow with PKCE. The BFF acts as a confidential client — it has a client_secret that is never exposed to the browser.

sequenceDiagram
    participant User as User (Browser)
    participant SPA as SPA
    participant BFF as BFF Server
    participant Cache as Redis Cache
    participant IdP as Identity Provider

    User->>SPA: Click "Login"
    SPA->>BFF: GET /bff/login

    Note over BFF: Generate PKCE pair:<br/>code_verifier (64 bytes, base64url)<br/>code_challenge = SHA256(code_verifier)

    Note over BFF: Generate state parameter<br/>(32 random bytes, base64)

    BFF->>Cache: Store {code_verifier, state}<br/>key: bff:pkce:{state}<br/>TTL: 10 minutes

    BFF->>User: 302 Redirect to IdP<br/>/connect/authorize?<br/>client_id=...&<br/>response_type=code&<br/>code_challenge=...&<br/>code_challenge_method=S256&<br/>state=...&<br/>scope=openid profile email roles offline_access

    User->>IdP: Follow redirect
    IdP->>User: Login page
    User->>IdP: Enter credentials
    IdP->>User: Consent (if required)

    IdP->>BFF: 302 Redirect to /bff/callback?code=...&state=...

    BFF->>Cache: Get + remove bff:pkce:{state}
    Cache->>BFF: {code_verifier, state}

    BFF->>IdP: POST /connect/token<br/>grant_type=authorization_code&<br/>code=...&<br/>client_id=...&<br/>client_secret=...&<br/>code_verifier=...&<br/>redirect_uri=.../bff/callback

    IdP->>BFF: {access_token, refresh_token, id_token, expires_in}

    Note over BFF: Generate session ID<br/>(Guid, no dashes)

    BFF->>Cache: Store BffTokenSet<br/>key: bff:session:{sessionId}<br/>TTL: SessionDuration (8h default)

    BFF->>User: 302 Redirect to PostLoginRedirectPath<br/>Set-Cookie: __Host-granit-bff={sessionId};<br/>HttpOnly; Secure; SameSite=Strict; Path=/

    Note over User: Cookie stored automatically<br/>by the browser. Invisible<br/>to JavaScript.

    BFF->>BFF: RecordLogin metric

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Even though the BFF is a confidential client, PKCE provides defense-in-depth:

ParameterValue
code_verifier64 random bytes, base64url-encoded
code_challengeBASE64URL(SHA256(code_verifier))
code_challenge_methodS256
StorageDistributed cache, 10-minute TTL

The code_verifier is generated server-side and never sent to the browser. After the callback, it is consumed (get + delete) to prevent replay attacks.

When the SPA makes an API call, the browser automatically attaches the session cookie. The YARP reverse proxy intercepts the request, loads the tokens from Redis, and injects the Authorization: Bearer header before forwarding.

sequenceDiagram
    participant SPA as SPA
    participant BFF as BFF Server (YARP)
    participant Cache as Redis Cache
    participant API as Backend API

    SPA->>BFF: GET /api/products<br/>Cookie: __Host-granit-bff={sessionId}<br/>X-CSRF-Token: {csrfToken} (if mutating)

    Note over BFF: BffCsrfValidationTransform<br/>(only for POST/PUT/DELETE/PATCH)

    Note over BFF: BffTokenInjectionTransform

    BFF->>BFF: Read session cookie
    BFF->>Cache: Get bff:session:{sessionId}
    Cache->>BFF: BffTokenSet {accessToken, refreshToken, idToken, expiresAt}

    alt Token valid (not expiring soon)
        BFF->>API: GET /api/products<br/>Authorization: Bearer {accessToken}
        API->>BFF: 200 OK {data}
        BFF->>SPA: 200 OK {data}
    else Token expiring within grace period
        Note over BFF: See Token Refresh Flow
    end

    BFF->>BFF: RecordProxyRequest metric

Not all YARP routes require authentication. The Granit.Bff.RequireAuth metadata key controls which routes trigger token injection and CSRF validation:

{
"Routes": {
"api-route": {
"ClusterId": "backend",
"Match": { "Path": "/api/{**catch-all}" },
"Metadata": {
"Granit.Bff.RequireAuth": "true"
}
},
"public-route": {
"ClusterId": "backend",
"Match": { "Path": "/public/{**catch-all}" }
}
}
}

Routes without the metadata key (or with "false") pass through without authentication — useful for public endpoints, health checks, or OpenAPI specs.

When the access token is about to expire (within the configurable RefreshGracePeriod, default 1 minute), the YARP token injection transform automatically performs a silent refresh. This is invisible to both the SPA and the end user.

sequenceDiagram
    participant SPA as SPA
    participant BFF as BFF Server (YARP)
    participant Cache as Redis Cache
    participant IdP as Identity Provider
    participant API as Backend API

    SPA->>BFF: GET /api/orders<br/>Cookie: __Host-granit-bff={sessionId}

    BFF->>Cache: Get bff:session:{sessionId}
    Cache->>BFF: BffTokenSet {expiresAt: 11:59:30}

    Note over BFF: Current time: 11:59:00<br/>Grace period: 1 minute<br/>Token expires in 30s → REFRESH

    BFF->>IdP: POST /connect/token<br/>grant_type=refresh_token&<br/>refresh_token=...&<br/>client_id=...&<br/>client_secret=...

    IdP->>BFF: {access_token (new), refresh_token (new), expires_in: 3600}

    BFF->>Cache: Update bff:session:{sessionId}<br/>with new BffTokenSet

    BFF->>BFF: RecordTokenRefresh metric

    BFF->>API: GET /api/orders<br/>Authorization: Bearer {new_access_token}
    API->>BFF: 200 OK {data}

    BFF->>SPA: 200 OK {data}<br/>X-Bff-Session-Refreshed: true

If the refresh token is expired or revoked by the identity provider:

stateDiagram-v2
    [*] --> CheckExpiry: Proxy request received
    CheckExpiry --> InjectToken: Token still valid
    CheckExpiry --> TryRefresh: Token expiring soon
    TryRefresh --> InjectToken: Refresh succeeded
    TryRefresh --> Return401: Refresh failed
    InjectToken --> ForwardRequest: Bearer header set
    ForwardRequest --> [*]: Response returned
    Return401 --> [*]: SPA redirects to /bff/login

When refresh fails, the BFF returns 401 Unauthorized. The SPA should detect this and redirect the user to /bff/login to start a new authentication flow.

Logout involves three steps: clearing the server-side session, deleting the browser cookie, and notifying the identity provider via RP-Initiated Logout.

sequenceDiagram
    participant SPA as SPA
    participant BFF as BFF Server
    participant Cache as Redis Cache
    participant IdP as Identity Provider

    SPA->>BFF: GET /bff/logout

    BFF->>BFF: Read session cookie

    alt Session exists
        BFF->>Cache: Get bff:session:{sessionId}
        Cache->>BFF: BffTokenSet (extract id_token)
        BFF->>Cache: Remove bff:session:{sessionId}
        BFF->>BFF: RecordLogout metric
    end

    BFF->>SPA: Delete __Host-granit-bff cookie<br/>(Set-Cookie with MaxAge=0)

    BFF->>SPA: 302 Redirect to IdP<br/>/connect/endsession?<br/>post_logout_redirect_uri=...&<br/>client_id=...&<br/>id_token_hint=... (if available)

    SPA->>IdP: Follow redirect
    IdP->>IdP: End session
    IdP->>SPA: 302 Redirect to post_logout_redirect_uri

    Note over SPA: User is logged out<br/>everywhere

The id_token_hint parameter tells the identity provider which session to terminate without prompting the user for confirmation. If the ID token is available in the session store, the BFF includes it automatically. If the session has already expired, logout still works — the user is simply shown the IdP’s logout confirmation page.

When a user logs out, the BFF revokes both the refresh token and access token at the authorization server’s /connect/revoke endpoint (RFC 7009) before clearing the local session. This is a best-effort operation — logout always completes even if revocation fails (e.g., the IdP is temporarily unreachable). Revocation order: refresh token first (cascades to access token in OpenIddict), then explicit access token revocation for defense in depth. DPoP proofs and private_key_jwt assertions are included in revocation requests when those features are enabled.

The BFF supports OIDC Back-Channel Logout 1.0. When a user logs out at the identity provider (or an admin revokes a session), the IdP sends a signed logout_token to /{pathPrefix}/bff/backchannel-logout. The endpoint validates the JWT signature, checks for replay (jti), verifies the issuer, and removes all matching sessions for the subject from the token store. This ensures server-side session cleanup even when the user closes the browser without calling /bff/logout.

The BFF provides endpoints for listing and revoking active sessions per user:

EndpointMethodDescription
/{prefix}/bff/sessionsGETLists the current user’s active sessions (masked IDs, timestamps, user agents)
/{prefix}/bff/sessions/{id}DELETERevokes a specific session (“log out that device”)
/{prefix}/bff/sessionsDELETERevokes all other sessions (“log out everywhere else”)

These endpoints require a valid session cookie. The current session cannot be revoked via DELETE /sessions/{id} — use /bff/logout instead.

By default (UseSessionSlidingExpiration: true), the BFF extends the session TTL when a proxied request occurs past the session’s halfway point. This prevents active users from being logged out mid-work while still enforcing a hard ceiling via SessionAbsoluteMaxDuration (default: 8 hours) to meet ISO 27001 A.9.4.2.

The IBffTokenStore abstraction supports two persistence backends:

StrategyPackageUse case
Distributed cache (default)Granit.BffRedis, SQL Server, or in-memory. Best for horizontally scaled deployments with existing Redis infrastructure.
EF CoreGranit.Bff.EntityFrameworkCoreStores sessions in BffDbContext (table bff_sessions). Best for deployments without Redis, or when session data must be queryable (session listing, admin dashboards). Includes an automatic expired session cleanup job.

To switch to EF Core, add a [DependsOn(typeof(GranitBffEntityFrameworkCoreModule))] to your module. This replaces the default IBffTokenStore registration with EfCoreBffTokenStore backed by BffDbContext.

A clear picture of where each piece of sensitive data resides:

DataLocationFormatLifetimeAccessible to JS
Access tokenRedis (bff:session:{id})JSON (serialized BffTokenSet)SessionDuration (default 8h)No
Refresh tokenRedis (bff:session:{id})JSON (serialized BffTokenSet)SessionDuration (default 8h)No
ID tokenRedis (bff:session:{id})JSON (serialized BffTokenSet)SessionDuration (default 8h)No
Session IDBrowser cookie (__Host-granit-bff)GUID (32 hex chars)SessionDurationNo (HttpOnly)
CSRF tokenSPA memory (from /bff/csrf-token){timestamp}:{hmac-hex}24h sliding windowYes (intentional)
PKCE code_verifierRedis (bff:pkce:{state})JSON10 minutesNo

The middleware and transform order is critical for correct behavior:

flowchart TD
    A[Incoming Request] --> B{Path starts with /bff/?}
    B -->|Yes| C[BffSecurityHeadersMiddleware<br/>X-Frame-Options + CSP on login/callback]
    C --> D[BFF Endpoints<br/>login / callback / logout / user / csrf-token]
    B -->|No| E{YARP route match?}
    E -->|Yes| F[BffCsrfValidationTransform<br/>Validate X-CSRF-Token on mutating methods]
    F --> G{CSRF valid?}
    G -->|No| H[403 Forbidden]
    G -->|Yes| I[BffTokenInjectionTransform<br/>Load session → refresh if needed → inject Bearer]
    I --> J{Session valid?}
    J -->|No| K[401 Unauthorized]
    J -->|Yes| L[Forward to upstream API]
    L --> M[Response to SPA]
    E -->|No| N[Static files / SPA fallback]
  • Getting Started — set up a working BFF in 15 minutes
  • Security — deep dive into cookie hardening, CSRF, and session management
  • YARP Proxy — route configuration and multi-service proxying