n8n16 min readPublished September 2026

n8n refreshes your token only when the API says 401

If the service you connect to answers an expired token with 403, 400, or a 200 that carries the error in the body, n8n never notices the token died and never refreshes it. There is a setting that fixes the 403 case, and the built-in credentials do not show it to you.

The symptom, which sounds like nothing

It worked yesterday. Today the workflow fails with something about credentials. You open the credential, reconnect it, everything runs. Tomorrow morning it fails again.

Somebody in the n8n community described the loop exactly:

The credential would work fine, and everything would be set up correctly using Client ID : Client Secret. I would come back to do more work the next day, and end up troubleshooting for an hour or more trying to understand why it was saying my credentials were expired. Refreshing the credential secret would work temporarily, but then would go right back to failing the next day.

Asked what the API actually returned, the same person answered: "if I recall, it was giving me a 403 or a 400 after 24 hours had passed."

That is the whole bug, and it is not really a bug. It is a design decision with a number in it.

What n8n checks before deciding to refresh

One status code, compared for exact equality.

When a request through an OAuth2 credential fails, n8n compares the response status against a value that resolves, in order, from the credential, then from the node's options, then to a default of 401. If the numbers match, it refreshes the token and retries. If they do not, the failure is passed on as an ordinary error and the token is left alone, still expired, ready to fail the same way on the next run.

Three consequences follow directly from "exact equality against one number":

  • 403 does not trigger a refresh. Neither does 400.
  • A 200 with an error inside the body does not reach the retry logic at all, because as far as the HTTP layer is concerned nothing failed.
  • Configuring a different code replaces 401 rather than adding to it.

That last one is not a guess. The test is named "should NOT retry on 401 when credential sets tokenExpiredStatusCode to 403 (isN8nRequest path)". You are choosing which single code means "expired", not building a list.

Which APIs do not answer 401?

More than you would guess, and several of them are ones a small business automation actually touches.

ServiceWhat it returns for an expired or rejected token
Google Drive, Gmail401. "These errors mean the request doesn't contain a valid access token", with the instruction to "refresh the access token using the long-lived refresh token"
Microsoft Graph401 for missing or invalid auth, and a documented 403 when conditional access policies apply, returned as "HTTP 403; Forbidden error=insufficient_claims"
HubSpot401 "when the authentication provided is invalid", 403 when the token lacks the scope
Shopify401 for credentials, 403 for scopes, and 402 when the shop is frozen for non-payment
Databricks403, according to n8n's own source comment
Adobe MarketoHTTP 200, with 602 Access token expired in the body, as quoted by a reporter from Adobe's error-code documentation

Google is explicit that this split is deliberate, and its API design guide states the rule that most well-behaved APIs follow: a permission problem "must error with PERMISSION_DENIED (HTTP 403)". So 403 is supposed to mean "you, but not allowed", and an expired token is supposed to be 401.

The trouble is that plenty of real services do not follow it. In the n8n issue on this subject, users report the same 403 from Microsoft Graph and from Power BI, and one reports that a Zoho API "doesn't return an 401" either. In a separate forum thread somebody reports a 403 or 400 from a PSA tool. Those are user reports rather than vendor documentation, which is exactly the position you are in when it happens to you: the only evidence is your own failed run.

The quick test. Run the failing step and look at the status code, not the message. If it is 401, this article is not your problem. If it is 403, 400, or a 200 whose body contains an error, it probably is.

Is there a setting for this?

There is, and it has a name: tokenExpiredStatusCode. It arrived in a pull request titled "feat(core): Add configurable HTTP status code for OAuth2 token refresh", whose summary says the quiet part plainly:

Added support for configurable HTTP status codes to trigger OAuth2 token refresh. Previously, only 401 was checked. This PR allows users to specify custom status codes (e.g., 403) that indicate token expiration for APIs that return non-standard codes.

  • PR #26641, n8n on GitHub, opened 5 March 2026, merged 6 March 2026

It shipped in n8n 2.12.0 on 9 March 2026, and it works.

The request for it is older than that by almost exactly five years. In March 2021 a user asked on the forum how to reach the setting, explaining that their API "returns 403, not 401" and that they had patched their own copy of n8n to check both. A member of the n8n team replied:

Ahh, gotcha. I understand now. Good question. Currently, it's not possible to set tokenExpiredStatusCode via the UI. However, it should not be difficult to add. I just added it to my to-do list.

So why can you not find it in your credential?

Because on almost every built-in credential, it is deliberately removed.

The field is declared on the base OAuth2 credential, with a description that tells you precisely what it is for: "HTTP status code that indicates the token has expired. Some APIs return 403 instead of 401." And it carries a flag, doNotInherit, which is honoured by the code that merges a parent credential's fields into a child one. Any property with that flag is skipped.

The built-in OAuth2 credentials all reach that base credential, most of them directly and the rest through an intermediate one: seventy-two extend it by name, and another three dozen inherit through the Google, Microsoft, Atlassian and Facebook bases. So the field exists, and it does not reach the Google credential, the HubSpot credential, the Microsoft credential, or any of the others.

You do not have to take an outsider's word for the consequence, because n8n's own source spells it out. In the Databricks credential, above a re-declared copy of the field:

Re-declared because the base oAuth2Api field is doNotInherit, so it never reaches the decrypted credential. Without it the value is always undefined and token refresh is hardcoded to 401 - Databricks returns 403 when tokens expire, so the default must be 403.

  • comment in DatabricksOAuth2Api.credentials.ts, n8n-io/n8n, read 7 September 2026

Databricks is the only extending credential that carries that workaround. Of the four hundred-odd credential files in the repository, exactly two mention the setting at all: the base itself, and Databricks.

Getting that one line into Databricks took six community pull requests, of which two were merged, which is worth knowing if your plan is to open one for your own service.

Where is the field actually visible?

On the generic credential. doNotInherit blocks inheritance downward; it does not hide the field on the credential that declares it.

So in the HTTP Request node, choosing a generic OAuth2 credential rather than a predefined one gives you a Token Expired Status Code box you can set to 403. That is the actual fix available today, and it is the reason this is a small job rather than a fork of n8n.

The cost is that you give up the predefined credential's convenience and configure the OAuth endpoints yourself, and that you are choosing 403 instead of 401 rather than in addition to it.

Somebody did propose handling both codes, for Microsoft Teams specifically, arguing that "Microsoft Graph API returns both 401 and 403 for expired tokens depending on the endpoint and scenario". That pull request was not merged, and the comparison in the shipped code is still against a single value.

Why does the documentation not mention any of this?

Because it does not. Searching the documentation repository for the setting's name returns nothing. Neither does searching for "token expired status", or for doNotInherit, or for the pull request number in the changelog.

The comparison that makes this stark is one page over. The documentation for the HTTP Request node's credentials lists the fields of the generic OAuth2 credential, and it includes Ignore SSL Issues, which is declared in the same source file immediately above the one that is missing. Three separate grant-type sections list the neighbouring field. None of them lists this one.

The pull request itself predicted this. Its own merge checklist has four boxes, two of them ticked, and one of the empty ones reads "Docs updated or follow-up ticket created". Six months later that box is still an accurate description of the state of the documentation.

It is the same arrangement as a workflow that is saved but not published. The behaviour is written down in the code and nowhere the user will look, so the usual way to learn it is to lose a day to it first.

What does n8n say when people report it?

That it is expected. Twice, from the same member of the n8n organisation, almost ten months apart:

At the moment we don't support oauth connections for services that return a 403 instead of a 401 which is typically a more standard response code. We do have a request open to change this but it has not been implemented yet, For now I am going to mark this as closed as while it is annoying it isn't strictly a bug.

  • a member of the n8n organisation, issue #17450, 19 August 2025

this is expected at the moment and is not something we treat as a bug although I do agree we should change this and we have the feature request ticket opened to implement this in the future.

Users pushed back on the first one, and the sharpest reply is worth quoting because it names the actual design question rather than complaining:

n8n (the client) is capable of knowing the expiry status of the token (by either reading the token itself in the case of structured/transparent token, or by introspection at the time of token creation in the case of opaque token) but instead makes the API request blind expecting every API in the world to respect a single status code.

There is a hint that this is understood internally. Answering the second issue, the same n8n member mentioned "a slightly different approach in mind that remove the need for the http status check and make it more reliable with a background refresh service". They added "I don't know what the status is on that". Treat it as a direction, not a date.

What if the API answers 200?

If your service returns HTTP 200 with the error inside the body, none of the above helps, because nothing ever fails.

A user hit this with Adobe Marketo, which answers an expired token with a 200 and an error code in the payload, and summarised the position:

Token refresh is reactive only: triggered when the HTTP response status matches tokenExpiredStatusCode (default 401). [...] n8n recently merged PR #26641 [...] this helps APIs that return 403, but does not help Marketo, which returns 200 with response-level error codes like 602.

The corresponding issue was opened and closed inside half an hour, labelled as working as expected. For this class of API, the only reliable approach is to stop waiting for a failure and refresh on a schedule instead, before the token can expire.

One more thing worth knowing about waiting for a fix

The most-resolved thread on this subject was not resolved by n8n. A user reported that Inoreader returned 403 for expired tokens and that their workflow therefore died permanently. Nearly five months later a developer from Inoreader turned up in the thread. The root cause was on their side, they had relaxed it, and they were now "sending 401 instead of 403 in this specific case".

The vendor changed its API to suit the automation tool. That is a good outcome and not a plan you can build on.

What to do when this is your problem

  1. Look at the status code, not the error text. 403, 400 or a 200 with an error in the body puts you here. 401 does not.
  2. If it is 403 and you can use the HTTP Request node with a generic OAuth2 credential, set Token Expired Status Code to 403. This is the smallest fix that exists.
  3. Remember it is a replacement, not an addition. After setting 403, a genuine 401 will no longer refresh. For services that use both, choose the one you actually see.
  4. If the API answers 200 with the error in the body, stop trying to react. Refresh the token on a schedule with a margin, or fetch a token at the start of each run and pass it as a header.
  5. Do not leave the reconnect-every-morning workaround in place. It works and it hides the problem, which means the failure resurfaces on a day when nobody is watching.
  6. Write down which service returned what. The evidence disappears from the execution log on the next retention cycle, and it is the only thing that distinguishes this from a dozen other credential problems.

When this is a job to hand over

Setting one field on one generic credential is a fifteen-minute change, and that one you should do on your own.

It becomes a job when the failing step is a predefined credential you cannot easily swap, when the API answers 200 and the whole approach has to change from reactive to scheduled, or when several workflows share one credential. That last case is the awkward one, because the fix has to land without disturbing the workflows that are currently fine. It is still a job you can scope by counting: how many credentials, how many workflows behind each, which service answers with what. A Fix is priced off that count, and the n8n work we take usually starts with it.

The shape to carry away is that a credential is not either valid or invalid. It can be dead while still looking connected, and the only reason your platform ever notices is a number it happens to be watching for. When the service picks a different number, nobody finds out until somebody checks the runs.

Sources

Broken workflow? Fix S — $300, 2 business days, fixed price.

Get my quote in 24h

Written by the Fixmation team.