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.
Cookie security
Section titled “Cookie security”The session cookie is the only credential visible to the browser. Its configuration is critical for security.
Cookie attributes
Section titled “Cookie attributes”Set-Cookie: __Host-granit-bff=a1b2c3d4e5f6...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=28800| Attribute | Value | Purpose |
|---|---|---|
| Name | __Host-granit-bff | __Host- prefix enforces Secure + Path=/ + no Domain |
| HttpOnly | true | JavaScript cannot read the cookie (XSS-safe) |
| Secure | true | Cookie only sent over HTTPS |
| SameSite | Strict | Cookie never sent on cross-site requests (CSRF-safe) |
| Path | / | Available on all paths (required by __Host- prefix) |
| Max-Age | 28800 (8h default) | Matches SessionDuration configuration |
| IsEssential | true | Sent even when user declines non-essential cookies |
The __Host- prefix
Section titled “The __Host- prefix”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
Secureflag is mandatory - Subdomain cookie sharing — no
Domainattribute means the cookie is strictly bound to the exact host - Path-scoped attacks —
Path=/is mandatory, preventing path-based isolation bypass
Why SameSite=Strict and not Lax
Section titled “Why SameSite=Strict and not Lax”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:
- The user navigates to
/bff/login(same-site) which returns a 302 to the IdP - The IdP redirects back to
/bff/callback(same-site navigation from the user’s perspective) - The browser includes the cookie because the final request is same-site
Cookie vs token comparison
Section titled “Cookie vs token comparison”| Attack | Cookie (__Host-, HttpOnly, Strict) | Token in localStorage |
|---|---|---|
| XSS reads credential | Impossible (HttpOnly) | Token stolen |
| XSS exfiltrates credential | Impossible | fetch() to attacker server |
| CSRF | Blocked (SameSite=Strict) | N/A (no cookies) |
| Man-in-the-middle | Blocked (Secure, HTTPS only) | Depends on app |
| Subdomain takeover | Blocked (__Host- prefix) | N/A |
CSRF protection
Section titled “CSRF protection”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.
How the double-submit pattern works
Section titled “How the double-submit pattern works”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
HMAC-SHA256 token format
Section titled “HMAC-SHA256 token format”The CSRF token format is {timestamp}:{hmac-hex}:
1711234567:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12| Part | Description |
|---|---|
timestamp | Unix epoch seconds when the token was generated |
hmac-hex | HMAC-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
Validation rules
Section titled “Validation rules”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
Security properties
Section titled “Security properties”| Property | Implementation |
|---|---|
| Session binding | HMAC payload includes sessionId — token is useless for other sessions |
| Timestamp freshness | 24-hour sliding window prevents token reuse after key rotation |
| Tamper resistance | HMAC-SHA256 — attacker cannot forge without the server key |
| Timing attack resistance | CryptographicOperations.FixedTimeEquals() for constant-time comparison |
| No database lookups | Validation is purely computational — zero I/O |
Clickjacking protection
Section titled “Clickjacking protection”The BffSecurityHeadersMiddleware adds anti-framing headers on login and callback
endpoints to prevent clickjacking attacks:
X-Frame-Options: DENYContent-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.
Token storage
Section titled “Token storage”Cache key format
Section titled “Cache key format”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.
Serialization format
Section titled “Serialization format”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"}Encryption at rest
Section titled “Encryption at rest”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.
For additional protection, replace the default IBffTokenStore with a custom
implementation that uses ICacheValueEncryptor from Granit.Caching:
internal sealed class EncryptedBffTokenStore( IDistributedCache cache, ICacheValueEncryptor encryptor, IOptions<GranitBffOptions> options, IClock clock) : IBffTokenStore{ private const string KeyPrefix = "bff:session:";
public async Task StoreAsync( string sessionId, BffTokenSet tokens, CancellationToken cancellationToken = default) { byte[] json = JsonSerializer.SerializeToUtf8Bytes(tokens); byte[] encrypted = encryptor.Encrypt(json); // AES-256-CBC
await cache.SetAsync( $"{KeyPrefix}{sessionId}", encrypted, new DistributedCacheEntryOptions { AbsoluteExpiration = clock.Now.Add(options.Value.SessionDuration), }, cancellationToken).ConfigureAwait(false); }
public async Task<BffTokenSet?> GetAsync( string sessionId, CancellationToken cancellationToken = default) { byte[]? encrypted = await cache.GetAsync( $"{KeyPrefix}{sessionId}", cancellationToken).ConfigureAwait(false);
if (encrypted is null or { Length: 0 }) return null;
byte[] json = encryptor.Decrypt(encrypted); return JsonSerializer.Deserialize<BffTokenSet>(json); }
// RemoveAsync remains unchanged}Register it in DI to replace the default:
services.AddScoped<IBffTokenStore, EncryptedBffTokenStore>();With Granit.Caching, the AesCacheValueEncryptor uses AES-256-CBC with a
random IV per encryption call. The key is configured via CachingOptions.EncryptionKey
(sourced from Vault in production).
Session management
Section titled “Session management”Session lifecycle
Section titled “Session lifecycle”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
Session duration
Section titled “Session duration”| Property | Default | Description |
|---|---|---|
SessionDuration | 8 hours | Absolute expiry — after this, the user must re-authenticate |
RefreshGracePeriod | 1 minute | Token is refreshed this long before it expires |
Cookie Max-Age | Matches SessionDuration | Browser deletes the cookie when it expires |
| Cache entry TTL | Matches SessionDuration | Redis auto-evicts the session data |
Sliding expiration (default)
Section titled “Sliding expiration (default)”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.
Session revocation
Section titled “Session revocation”Sessions can be revoked in three ways:
-
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”)
-
Back-channel logout — when the IdP sends a
logout_tokento/{prefix}/bff/backchannel-logout, all sessions for that subject are removed. -
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.
Attack mitigation summary
Section titled “Attack mitigation summary”| Attack | Mitigation | Granit.Bff mechanism |
|---|---|---|
| XSS token theft | Tokens never in browser | Server-side storage, HttpOnly cookie |
| XSS cookie theft | HttpOnly flag | CookieOptions.HttpOnly = true |
| CSRF | SameSite=Strict + double-submit | Cookie config + BffCsrfValidationTransform |
| Session fixation | Fresh session ID on login | Guid.NewGuid() after token exchange |
| Session hijacking | __Host- prefix + Secure | Cookie only sent over HTTPS, no subdomain sharing |
| Token replay | Short-lived access tokens | IdP-configured expiry + auto-refresh + DPoP |
| Token leakage after logout | Token revocation (RFC 7009) | RevokeTokensAsync on /bff/logout |
| IdP session revocation | Back-channel logout (OIDC) | /{prefix}/bff/backchannel-logout endpoint |
| Clickjacking | X-Frame-Options + CSP | BffSecurityHeadersMiddleware |
| Authorization code interception | PKCE | code_challenge + code_verifier |
| Token storage compromise | Redis TLS + optional AES encryption | DistributedCacheBffTokenStore + ICacheValueEncryptor |
| CSRF token forgery | HMAC-SHA256 with server key | HmacBffCsrfTokenGenerator |
| Timing attacks on CSRF | Constant-time comparison | CryptographicOperations.FixedTimeEquals() |
| Stale sessions | Absolute expiry | SessionDuration TTL on cache + cookie |
Compliance notes
Section titled “Compliance notes”ISO 27001
Section titled “ISO 27001”- 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,tenantIdexposed to SPA - Storage limitation: session data auto-expires (configurable, default 8h)
- Right to erasure: session revocation = immediate data deletion from Redis
Next steps
Section titled “Next steps”- Architecture — review the full flow diagrams
- YARP Proxy — route configuration and token injection pipeline
- Configuration — full options reference