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):
-
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
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.
-
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
NSDate *expirationDate = [requestDate dateByAddingTimeInterval:secondsToLive];
-
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
-
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
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
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:
- Configure App Check with any provider and a 1-hour TTL.
- Set a breakpoint on
-[GACAppCheckAPIService appCheckTokenWithAPIResponse:] (12.0.0: AppCheckCoreAPIService.appCheckToken(withAPIResponse:)).
- Trigger a token fetch. When the breakpoint hits, the response — and therefore the minted JWT — already exists.
- Wait more than one hour, then resume.
- 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.
- 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
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
{
"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"
}
}
]
}
Description
AppCheckCorederives a cached token'sexpirationDatefrom the local time at which the client happens to process the HTTP response, rather than from the token's ownexpclaim. 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):
The token is constructed inside the
.thenblock that runs after the response has been received and status-validated, and[NSDate date]is evaluated at that moment and passed asrequestDate:https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m#L171-L183
The parameter name is misleading: this is the response-handling timestamp, not the time the request was issued.
Expiry is then computed purely from that timestamp plus the
ttlfield in the response body. The JWT's ownexp/iatclaims are never parsed:https://github.com/google/app-check/blob/CocoaPods-11.3.1/AppCheckCore/Sources/Core/APIService/GACAppCheckToken%2BAPIResponse.m#L86
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
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
expis already in the past withnow + ttl, writes it to the keychain, and treats it as fresh.From that point,
token(forcingRefresh: false)keeps returning it for up tottl - 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
expclaim in the returned JWT is authoritative.client_response_handling_time + ttl, which can be arbitrarily later than the realexp.Suggested fix
Parse the
expclaim 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
https://github.com/google/app-check/blob/CocoaPods-12.0.0/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken%2BAPIResponse.swift#L57
(Filing here rather than on
google/app-checksince 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:
-[GACAppCheckAPIService appCheckTokenWithAPIResponse:](12.0.0:AppCheckCoreAPIService.appCheckToken(withAPIResponse:)).GACAppCheckToken:expirationDateis ~1 hour from now, while the JWT'sexpclaim is already in the past. Decode the token payload to confirm.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
URLSessionthat 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
If using Swift Package Manager, the project's Package.resolved
Expand
Package.resolvedsnippet{ "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" } } ] }