Skip to content

Security

The BFF pattern is only as secure as its implementation. This page covers every security mechanism in Granit.Bff and how each one mitigates specific attack vectors.

The session cookie is the only credential visible to the browser. Its configuration is critical for security.

Set-Cookie: __Host-granit-bff=a1b2c3d4e5f6...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=28800
AttributeValuePurpose
Name__Host-granit-bff__Host- prefix enforces Secure + Path=/ + no Domain
HttpOnlytrueJavaScript cannot read the cookie (XSS-safe)
SecuretrueCookie only sent over HTTPS
SameSiteStrictCookie never sent on cross-site requests (CSRF-safe)
Path/Available on all paths (required by __Host- prefix)
Max-Age28800 (8h default)Matches SessionDuration configuration
IsEssentialtrueSent even when user declines non-essential cookies

The __Host- cookie prefix is a browser-enforced security mechanism (RFC 6265bis). When a cookie name starts with __Host-, the browser rejects it unless all of these conditions are met:

flowchart TD
    A["Set-Cookie: __Host-granit-bff=..."]
    A --> B{Secure flag?}
    B -->|No| X["Browser REJECTS the cookie"]
    B -->|Yes| C{Path = / ?}
    C -->|No| X
    C -->|Yes| D{Domain attribute absent?}
    D -->|No| X
    D -->|Yes| E["Browser ACCEPTS the cookie"]

    style X fill:#e74c3c,color:#fff
    style E fill:#2ecc71,color:#fff

This prevents:

  • Cookie injection via HTTP — the Secure flag is mandatory
  • Subdomain cookie sharing — no Domain attribute means the cookie is strictly bound to the exact host
  • Path-scoped attacksPath=/ is mandatory, preventing path-based isolation bypass

SameSite=Strict means the cookie is never sent on cross-site navigations, even top-level ones. This provides the strongest CSRF protection because an attacker cannot trick the browser into sending the cookie from a different origin.

The OIDC redirect flow works because:

  1. The user navigates to /bff/login (same-site) which returns a 302 to the IdP
  2. The IdP redirects back to /bff/callback (same-site navigation from the user’s perspective)
  3. The browser includes the cookie because the final request is same-site
AttackCookie (__Host-, HttpOnly, Strict)Token in localStorage
XSS reads credentialImpossible (HttpOnly)Token stolen
XSS exfiltrates credentialImpossiblefetch() to attacker server
CSRFBlocked (SameSite=Strict)N/A (no cookies)
Man-in-the-middleBlocked (Secure, HTTPS only)Depends on app
Subdomain takeoverBlocked (__Host- prefix)N/A

Even with SameSite=Strict, Granit.Bff implements a second layer of CSRF protection using the double-submit pattern. This provides defense-in-depth and protects against browser bugs or policy misconfigurations.

sequenceDiagram
    participant SPA as SPA
    participant BFF as BFF Server

    Note over SPA: After login, fetch CSRF token

    SPA->>BFF: POST /bff/csrf-token<br/>Cookie: __Host-granit-bff={sessionId}
    BFF->>BFF: Generate HMAC-SHA256 token<br/>payload = sessionId + timestamp<br/>token = timestamp:hmac(key, payload)
    BFF->>SPA: { "csrfToken": "1711234567:a1b2c3..." }

    Note over SPA: Store token in memory<br/>(not localStorage)

    SPA->>BFF: POST /api/orders<br/>Cookie: __Host-granit-bff={sessionId}<br/>X-CSRF-Token: 1711234567:a1b2c3...

    BFF->>BFF: BffCsrfValidationTransform<br/>1. Extract sessionId from cookie<br/>2. Extract token from X-CSRF-Token header<br/>3. Parse timestamp from token<br/>4. Recompute HMAC with sessionId + timestamp<br/>5. Constant-time compare

    alt Token valid
        BFF->>BFF: Continue to token injection
    else Token invalid or missing
        BFF->>SPA: 403 Forbidden
        BFF->>BFF: RecordCsrfRejection metric
    end

The CSRF token format is {timestamp}:{hmac-hex}:

1711234567:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12
PartDescription
timestampUnix epoch seconds when the token was generated
hmac-hexHMAC-SHA256 of {sessionId}:{timestamp} using a server-side key

The HMAC key is:

  • 32 random bytes generated at application startup
  • Per-instance — each BFF server instance has its own key
  • In-memory only — never persisted, never exposed

The BffCsrfValidationTransform applies these checks in order:

flowchart TD
    A[Incoming request] --> B{HTTP method?}
    B -->|GET / HEAD / OPTIONS| C[Skip CSRF validation]
    B -->|POST / PUT / DELETE / PATCH| D{Route has Granit.Bff.RequireAuth?}
    D -->|No| C
    D -->|Yes| E{Session cookie present?}
    E -->|No| F[Skip — token injection will handle 401]
    E -->|Yes| G{X-CSRF-Token header present?}
    G -->|No| H["403 Forbidden<br/>+ RecordCsrfRejection metric"]
    G -->|Yes| I{Parse timestamp:hmac format?}
    I -->|No| H
    I -->|Yes| J{Timestamp within 24h window?}
    J -->|No| H
    J -->|Yes| K{Recompute HMAC matches?<br/>Constant-time comparison}
    K -->|No| H
    K -->|Yes| L[CSRF valid — continue]

    style H fill:#e74c3c,color:#fff
    style L fill:#2ecc71,color:#fff
PropertyImplementation
Session bindingHMAC payload includes sessionId — token is useless for other sessions
Timestamp freshness24-hour sliding window prevents token reuse after key rotation
Tamper resistanceHMAC-SHA256 — attacker cannot forge without the server key
Timing attack resistanceCryptographicOperations.FixedTimeEquals() for constant-time comparison
No database lookupsValidation is purely computational — zero I/O

The BffSecurityHeadersMiddleware adds anti-framing headers on login and callback endpoints to prevent clickjacking attacks:

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

These headers are added on any request matching:

  • /bff/login*
  • /bff/callback*
flowchart TD
    A[Request to /bff/login] --> B[BffSecurityHeadersMiddleware]
    B --> C["Add X-Frame-Options: DENY"]
    C --> D["Add CSP: frame-ancestors 'none'"]
    D --> E[Continue to endpoint]

    F[Request to /api/products] --> G[BffSecurityHeadersMiddleware]
    G --> H[No headers added — path does not match]
    H --> I[Continue to YARP proxy]

This prevents an attacker from embedding the login page in a hidden iframe and tricking users into entering credentials.

Tokens are stored in IDistributedCache with the key format:

bff:session:{sessionId}

Where sessionId is a GUID without dashes (32 hex characters), generated by Guid.NewGuid().ToString("N").

PKCE state is stored separately with:

bff:pkce:{state}

Where state is a base64-encoded random value (32 bytes). This entry has a 10-minute TTL and is consumed (get + delete) during the callback.

The BffTokenSet record is serialized as JSON with camelCase naming:

{
"accessToken": "<encrypted-jwt-access-token>",
"refreshToken": "<encrypted-refresh-token>",
"idToken": "<encrypted-jwt-id-token>",
"expiresAt": "2026-03-22T19:30:00+00:00"
}

The default DistributedCacheBffTokenStore stores tokens as plain JSON in the distributed cache. For production deployments handling sensitive data, you have two options for encryption at rest:

Enable TLS on the Redis connection to encrypt data in transit and at rest (if Redis persistence is enabled):

{
"ConnectionStrings": {
"Redis": "redis.example.com:6380,ssl=true,password=...,abortConnect=false"
}
}

This protects against network sniffing and disk access to Redis RDB/AOF files.

stateDiagram-v2
    [*] --> NoSession: User visits SPA
    NoSession --> LoginRedirect: GET /bff/login
    LoginRedirect --> OidcFlow: 302 to IdP
    OidcFlow --> SessionCreated: Callback + token exchange
    SessionCreated --> Active: Cookie set + tokens stored

    Active --> Active: API calls (cookie sent automatically)
    Active --> TokenRefreshed: Access token near expiry
    TokenRefreshed --> Active: New tokens stored

    Active --> Expired: SessionDuration exceeded
    Active --> Revoked: Admin removes from Redis
    Active --> LoggedOut: GET /bff/logout

    Expired --> NoSession: Cookie expired, /bff/user returns unauthenticated
    Revoked --> NoSession: 401 on next API call
    LoggedOut --> NoSession: Cookie deleted + IdP logout
PropertyDefaultDescription
SessionDuration8 hoursAbsolute expiry — after this, the user must re-authenticate
RefreshGracePeriod1 minuteToken is refreshed this long before it expires
Cookie Max-AgeMatches SessionDurationBrowser deletes the cookie when it expires
Cache entry TTLMatches SessionDurationRedis auto-evicts the session data

Granit.Bff uses sliding expiration by default (UseSessionSlidingExpiration: true). When a proxied request occurs past the session’s halfway point, the token store TTL is extended by SessionDuration. This prevents active users from being logged out mid-work.

To prevent indefinite sessions (ISO 27001 A.9.4.2), a hard ceiling is enforced via SessionAbsoluteMaxDuration (default: 8 hours). Once reached, the user must re-authenticate regardless of activity.

Set UseSessionSlidingExpiration: false to revert to absolute-only expiration where the session duration starts at login and is never extended.

Sessions can be revoked in three ways:

  1. User self-service — the BFF exposes session management endpoints per frontend:

    • GET /{prefix}/bff/sessions — list active sessions (masked IDs, timestamps, user agents)
    • DELETE /{prefix}/bff/sessions/{id} — revoke a specific session (“log out that device”)
    • DELETE /{prefix}/bff/sessions — revoke all other sessions (“log out everywhere else”)
  2. Back-channel logout — when the IdP sends a logout_token to /{prefix}/bff/backchannel-logout, all sessions for that subject are removed.

  3. Direct cache removal — for admin operations:

await cache.RemoveAsync($"bff:session:{sessionId}", cancellationToken);

All three methods are immediate — the next API call from the SPA receives 401 Unauthorized and the SPA’s 401 handler redirects to /bff/login.

AttackMitigationGranit.Bff mechanism
XSS token theftTokens never in browserServer-side storage, HttpOnly cookie
XSS cookie theftHttpOnly flagCookieOptions.HttpOnly = true
CSRFSameSite=Strict + double-submitCookie config + BffCsrfValidationTransform
Session fixationFresh session ID on loginGuid.NewGuid() after token exchange
Session hijacking__Host- prefix + SecureCookie only sent over HTTPS, no subdomain sharing
Token replayShort-lived access tokensIdP-configured expiry + auto-refresh + DPoP
Token leakage after logoutToken revocation (RFC 7009)RevokeTokensAsync on /bff/logout
IdP session revocationBack-channel logout (OIDC)/{prefix}/bff/backchannel-logout endpoint
ClickjackingX-Frame-Options + CSPBffSecurityHeadersMiddleware
Authorization code interceptionPKCEcode_challenge + code_verifier
Token storage compromiseRedis TLS + optional AES encryptionDistributedCacheBffTokenStore + ICacheValueEncryptor
CSRF token forgeryHMAC-SHA256 with server keyHmacBffCsrfTokenGenerator
Timing attacks on CSRFConstant-time comparisonCryptographicOperations.FixedTimeEquals()
Stale sessionsAbsolute expirySessionDuration TTL on cache + cookie
  • A.9.4.2 — Secure log-on: PKCE + confidential client, no tokens in browser
  • A.9.4.3 — Password management: delegated to IdP, BFF does not handle passwords
  • A.14.1.2 — Securing application services: TLS mandatory (__Host- prefix enforces it)
  • A.14.1.3 — Protecting application transactions: CSRF double-submit on all mutations
  • Data minimization: only sub, name, email, roles, tenantId exposed to SPA
  • Storage limitation: session data auto-expires (configurable, default 8h)
  • Right to erasure: session revocation = immediate data deletion from Redis