firebase / firebase/firebase-ios-sdk

[App Check] Cached token expiry is anchored to response-handling time, not the JWT's exp claim

Open
#16,573 0 comments 0 reactions 2 assignees Claimed by @ncooke3 View on GitHub
api: appcheck
Dominant language
C++
Stars
6.7k
Forks
1.8k
Avg merge
2d 14h
Merged PRs (30d)
72

Description

### Description

`AppCheckCore` derives a cached token's `expirationDate` from **the local time at which the client happens to process the HTTP response**, rather than from the token's own `exp` claim. Because every downstream validity decision reads only that locally-derived value, any delay between the App Check backend minting a token and the client executing the response handler is silently converted into extra apparent lifetime — and the SDK will then serve an already-expired token for up to a full TTL.

**The chain (AppCheckCore 11.3.1):**

1. The token is constructed inside the `.then` block that runs *after* the response has been received and status-validated, and `[NSDate date]` is evaluated at that moment and passed as `requestDate`:

https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m#L171-L183

```objc
GACAppCheckToken *token = [[GACAppCheckToken alloc]
initWithTokenExchangeResponse:response.HTTPBody
requestDate:[NSDate date]
error:&error];
```

The parameter name is misleading: this is the response-handling timestamp, not the time the request was issued.

2. Expiry is then computed purely from that timestamp plus the `ttl` field in the response body. The JWT's own `exp` / `iat` claims are never parsed:

https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/APIService/GACAppCheckToken%2BAPIResponse.m#L86

```objc
NSDate *expirationDate = [requestDate dateByAddingTimeInterval:secondsToLive];
```

3. Every subsequent validity decision consults only that stored `expirationDate` — the read-time freshness check (`kTokenExpirationThreshold`, 5 min):

https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/GACAppCheck.m#L166-L186

...and the auto-refresh scheduling (50% of TTL + 5 min):

https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.m#L161-L181

4. The token is persisted to the keychain with that inflated `expirationDate`, so the bad state survives process death.

**Consequence**

If the process is suspended or otherwise stalled between the response arriving and step 1 running — e.g. the app is backgrounded while a token exchange is in flight, then resumed much later — the SDK stamps a token whose real `exp` is already in the past with `now + ttl`, writes it to the keychain, and treats it as fresh.

From that point, `token(forcingRefresh: false)` keeps returning it for up to `ttl - 5min` (55 minutes with the default 1-hour TTL), the auto-refresh timer is scheduled ~35 minutes out based on the same wrong anchor, and a custom backend verifying the token rejects every request with an "expired token" error. A cold start does not recover: the poisoned token is read back from the keychain and still looks valid.

Note that a *constant* device-clock offset does **not** cause this — it cancels out, since both the stored expiry and the current time are read from the same clock. The failure comes specifically from the gap between the server's mint time and the client's response-handling time, which is unbounded.

### Expected vs actual

- **Expected:** the cached lifetime never exceeds the token's real server-side lifetime. The `exp` claim in the returned JWT is authoritative.
- **Actual:** the cached lifetime is `client_response_handling_time + ttl`, which can be arbitrarily later than the real `exp`.

### Suggested fix

Parse the `exp` claim from the returned JWT and use it, or at minimum clamp: `expirationDate = min(exp, requestDate + ttl)`. That keeps the existing conservative behaviour in the normal case (where the two are within a network round trip of each other) while making the pathological case impossible.

### Still present in AppCheckCore 12.0.0

The Objective-C → Swift rewrite preserved this behaviour verbatim, so upgrading does not help:

https://github.com/google/app-check/blob/CocoaPods-12.0.0/AppCheckCore/Sources/Core/APIService/AppCheckCoreAPIService.swift#L168-L175

```swift
return try AppCheckCoreToken(
tokenExchangeResponse: response.httpBody ?? Data(),
requestDate: Date()
)
```

https://github.com/google/app-check/blob/CocoaPods-12.0.0/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken%2BAPIResponse.swift#L57

```swift
let expirationDate = requestDate.addingTimeInterval(secondsToLive)
```

(Filing here rather than on `google/app-check` since the CHANGELOG entries there reference this tracker; happy to move it if you'd prefer.)

### Reproducing the issue

The natural trigger (app suspended mid-exchange) is timing-dependent, but the mechanism can be reproduced deterministically with a debugger:

1. Configure App Check with any provider and a 1-hour TTL.
2. Set a breakpoint on `-[GACAppCheckAPIService appCheckTokenWithAPIResponse:]` (12.0.0: `AppCheckCoreAPIService.appCheckToken(withAPIResponse:)`).
3. Trigger a token fetch. When the breakpoint hits, the response — and therefore the minted JWT — already exists.
4. Wait more than one hour, then resume.
5. Inspect the resulting `GACAppCheckToken`: `expirationDate` is ~1 hour from *now*, while the JWT's `exp` claim is already in the past. Decode the token payload to confirm.
6. Call `AppCheck.appCheck().token(forcingRefresh: false)` — the SDK returns the expired token from cache instead of minting a new one, and will continue to do so for the next ~55 minutes. Sending it to a backend that verifies App Check tokens yields an "expired" rejection.

The same can be done without a debugger by injecting a `URLSession` that withholds delivery of the exchange response for longer than the TTL.

### Firebase SDK Version

12.16.0 (AppCheckCore 11.3.1). Verified from source that the behaviour is unchanged in 12.18.0 and in AppCheckCore 12.0.0.

### Xcode Version

26.6

### Installation Method

Swift Package Manager

### Firebase Product(s)

App Check

### Targeted Platforms

iOS

### Relevant Log Output

```shell
n/a — the SDK logs nothing unusual; from its point of view the cached token is valid.
The failure is only observable at the backend verifying the token, or by decoding
the JWT's exp claim and comparing it to GACAppCheckToken.expirationDate.
```

### If using Swift Package Manager, the project's Package.resolved

Expand Package.resolved snippet

```json
{
"pins" : [
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "9ab4a7e6e5d6d3df2a2aa002a11d25e078c8d6e5",
"version" : "12.16.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
}
]
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.