DEV Community

Hammad Shams Uddin
Hammad Shams Uddin

Posted on

invalid_grant: my service account was fine. My laptop's clock was three days slow.

My daily SEO round emails me one digest. This morning it led with this:

NEEDS YOU — 5 thing(s)
  * Search Console could not be read today.
  ...

SEARCH CONSOLE — COULD NOT READ
  Token request failed (HTTP 400): {"error":"invalid_grant",
  "error_description":"Invalid JWT: Token must be a short-lived token
  (60 minutes) and in a reasonable timeframe. Check your iat and exp
  values in the JWT claim."}
Enter fullscreen mode Exit fullscreen mode

invalid_grant is the error you get when a credential is wrong, so that is where I went first: had the service account been disabled, had someone rotated the key, had I lost access to the property? I had spent six days cleaning a compromised hosting account the week before, so "the key is gone" was not a paranoid thought.

All of that was the wrong question, and the report had already told me so. I just had not read the line above the error.

The header was the evidence

The digest's own title line said:

Utilorax SEO — Thu 17 Sep 2026
Enter fullscreen mode Exit fullscreen mode

That date is generated by the script at run time, from the machine it runs on. I asked something with an authoritative clock what day it was:

$ curl -sI https://www.google.com | grep -i '^date:'
Date: Sun, 20 Sep 2026 21:03:40 GMT
Enter fullscreen mode Exit fullscreen mode

Three days apart. The machine that signed the token thought it was the 17th.

Why that is fatal to a signed token, and to nothing else

Here is the part of my client that builds the assertion:

$now   = time();
$claim = self::b64url((string) json_encode([
    'iss'   => $sa['client_email'],
    'scope' => self::SCOPE,
    'aud'   => $sa['token_uri'],
    'exp'   => $now + 3600,
    'iat'   => $now,
]));
Enter fullscreen mode Exit fullscreen mode

There is no bug in it. iat is now and exp is an hour from now — exactly what Google asks for. But now is whatever the machine says it is, and the machine was saying Thursday. So Google received a token that claimed to have been issued three days ago and to have expired two days and twenty-three hours ago. It refused, and its refusal named the two fields it was unhappy with: iat and exp. The error text was a precise description of the fault. I read it as a category — "auth error" — instead of as a sentence.

The giveaway I walked past: every other check in the same run was fine. IndexNow checked what had changed and correctly sent nothing, dev.to answered, the sitemap's 54 children were counted, the AI model probe came back. Not one of them signs anything, so not one of them cares what time my laptop thinks it is. The only check that broke was the only one holding a stopwatch.

Two minutes that would have saved an hour

If you get invalid_grant from a Google, Apple or AWS token endpoint, before you touch the key:

  1. Compare your clock to theirs. curl -sI https://www.google.com | grep -i ^date costs nothing and answers the question outright.
  2. Read the token you actually sent. The assertion is a JWT, so paste it into a JWT decoder and look at the payload rather than at your code. Your code says what you meant; the token says what you sent.
  3. Turn iat and exp into dates. They are Unix seconds, which are unreadable on sight — 1789938294 looks like every other ten-digit number. A Unix timestamp converter turns them into a date you can compare to today's, and the gap is either zero or your whole answer.

Rule of thumb worth internalising: a signed short-lived token is a claim about time, so a rejected one is as likely to be a clock complaint as a credential complaint. The word invalid_grant is doing double duty for both, which is why the error_description — not the error — is the field to read.

The one thing that went right

The round did not print zero impressions. It said Search Console could not be read today, kept the section, and put the raw HTTP body in it.

That distinction has bitten this project repeatedly in the other direction: a sitemap check that reported unreachable children as empty, a robots.txt check that passed for six days on an attacker's file, a fetch failure that arrived as "no data". Every one of them turned a failure to measure into a measurement. Here the instrument said it was blind, said why, and handed me the sentence that contained the answer.

I still needed three days of wrong clock before I read that sentence properly. But I got to read it, which is the whole difference.

(The clock has since resynced on its own. The same round, re-run: Search Console read, 46 pages snapshotted, nothing owed.)

Top comments (0)