Spurlock Studios
Contact
Share LinkedIn X
A calendar page turning. Thesis: N8N REFRESH OAUTH ACCESS TOKENS.

n8n refreshes OAuth access tokens by reading the refresh token out of the encrypted credential (oauthTokenData), POSTing grant_type=refresh_token to the vendor token URL, writing the new token payload back onto that same credential, and retrying the API call. On generic OAuth2 that refresh is reactive: it runs when the resource API returns tokenExpiredStatusCode (default 401), not when expires_in elapses on the clock. Google will not mint a refresh token at all unless the consent URL included access_type=offline. n8n’s built-in Google OAuth2 credential already sends access_type=offline&prompt=consent. A generic OAuth2 credential does not, until you put those values in Auth URI Query Parameters and reconnect.

This page is the mechanism. Silent overnight 401 loops, pause, and expiry alerts live in OAuth tokens will expire. The production spine is the Production n8n handbook. Across 600+ automations built and 500+ live, the “n8n does not refresh” ticket is usually “Google never issued a refresh token” or “the API never returned 401.”

I will not invent a studio-wide “refresh success rate.” Prove this credential with a captured status and an unattended retry.

The short answer

  • Store. Access token and refresh token live in the credential blob n8n encrypts before it writes the database. Workers share N8N_ENCRYPTION_KEY.
  • When. Generic OAuth2: after a matching HTTP status (default 401). Then persist. Then retry. Not “every 50 minutes because expires_in said so” — n8n staff have treated HTTP-200-with-a-dead-token-body as working-as-designed on that path (issue #32423).
  • Google extra config. Built-in Google OAuth2: already access_type=offline&prompt=consent in source (GoogleOAuth2Api.credentials.ts). Generic OAuth2: you type that string into Auth URI Query Parameters, set Authentication to Body, then Sign in again.
  • Check. Is this grant even allowed to have a refresh token? Did Google return one? Did the resource API return the status n8n watches? Did the new payload get written?
  • Not this page. Calendar for Google Testing grants, pause-on-auth, and one alert per credential — that is the expiry-ops post.
PieceWhat n8n doesWhat you configure
Token storeEncrypted oauthTokenData on the credentialEncryption key shared by every process
Refresh trigger (generic)Status == tokenExpiredStatusCodeDefault 401; change only if you captured 403-as-expiry
Refresh requestToken URL + grant_type=refresh_tokenAccess Token URL, client id/secret, Authentication Header vs Body
Google refresh token mintBuilt-in already asks offline+consentGeneric: Auth URI Query Parameters, then reconnect

Reconnect without those query params is a new access token and, often, still no refresh token. Google is explicit: the refresh token is only in the code-exchange response if access_type was offline on the authorize request (web server OAuth).

How does n8n store the refresh token?

It stores it inside the credential, encrypted, in the same payload as the access token. n8n encrypts credentials with an instance key before they hit the database. On first launch it generates that key into ~/.n8n unless you set N8N_ENCRYPTION_KEY. In queue mode every worker must use the same key or it cannot read — or write back — the blob after a refresh.

The field name operators hit in discussions and in n8n’s own OAuth helper is oauthTokenData. That object is what a successful Sign in writes, and what a successful refresh overwrites. It is not a node parameter. It is not workflow static data.

Google names the fields that land in that blob after a code exchange. refresh_token is only present if access_type=offline was on the authorize request (token response). If that key is missing, n8n stored an access token and a timer you are not using.

Key Google may returnWhat it isStored in oauthTokenData?
access_tokenBearer you send to Gmail/Sheets/DriveYes — this is what expires
expires_inAccess-token lifetime in secondsOften yes; generic n8n still waits for HTTP status
refresh_tokenGrant to mint a new access tokenOnly if Google issued it
refresh_token_expires_inOnly when the user granted time-based accessRare; do not assume it is always there
scopeSpace-delimited scopes actually grantedInformational
token_typeGoogle: BearerInformational
PlaceAllowed as the token store?Why
n8n Credentials UI / DB (oauthTokenData)YesEncrypted; refresh helper updates it
Secret manager injected at deployYes, if that is how you already inject credentialsStill one blob the runtime can update
Set node / sticky note / git JSONNoLeak, no rotation, refresh cannot write back
Workflow static data “until n8n supports X”NoSame anti-pattern n8n declined as the #32423 workaround

Procedure — treat storage as a write path, not a screenshot:

  1. Confirm the credential exists in Credentials, not as a header pasted into HTTP Request.
  2. Confirm every queue worker has the same N8N_ENCRYPTION_KEY.
  3. After Sign in, do not copy the token JSON into Slack “so we have a backup.”
  4. After a refresh, assume the new payload is the only payload. A stale worker that writes an older oauthTokenData last wins.

A refresh token n8n cannot persist is a refresh token you do not have. The next 401 has nothing to send to the token URL.

When does n8n refresh an access token?

On generic OAuth2, after the resource API returns the expired-token HTTP status. Default 401. Current generic OAuth2 source exposes Token Expired Status Code so you can set 403 when that is what the vendor actually returns (OAuth2Api.credentials.ts). Older n8n builds only matched 401 — hedge your version if the field is missing in the credential modal.

It does not mean: wake up because expires_in is stale. Community and GitHub have asked for proactive refresh from expiry metadata. As of the generic path documented in #32423, staff called status-only refresh working-as-designed. If a later n8n release adds an expires-before-request toggle, read that release note. Do not assume your graph already has it.

Numbered path for one HTTP Request with generic OAuth2 (authorization code):

  1. Node loads the credential and sends the current access token to the resource API.
  2. If the status is not tokenExpiredStatusCode, n8n returns that response. No token URL call.
  3. If the status matches, n8n POSTs to the Access Token URL with grant_type=refresh_token (or re-fetches for client credentials).
  4. On success it writes the new payload onto the credential.
  5. It retries the original resource request with the new access token.
  6. On token-URL failure the error surfaces. There is no silent Sign in.

That sequence is why a 200-with-dead-body never refreshes, and why a missing refresh token fails at step 3, not at step 1.

EventGeneric OAuth2What you see
Resource API returns 401 (default)Refresh (or client-credentials fetch), persist, retryOne failed status, then a success in the same execution if the grant is good
Resource API returns 403 meaning expiredRefresh only if you set the status field to 403“n8n never refreshes” until you change the field
Resource API returns 200 + error bodyNo refreshGreen node, dead token — detect the body yourself
Token URL returns invalid_grantRefresh already failedRe-consent. Status code will not invent a grant
Clock passed expires_in, no matching status yetNo refresh on generic pathNext call uses the dead access token until a matching status arrives

Decision list:

  1. Capture the status of a dead-token call, not only the JSON message.
  2. If it is not 401, set tokenExpiredStatusCode to the status you captured — or branch on the body if the status is 200.
  3. If it is 401 and refresh still never runs, you are missing a refresh token or the token URL rejected the grant.
  4. Do not add a Cron that “refreshes” by reconnecting. That is a human in a loop.

n8n’s HTTP Request docs are the operator surface for generic OAuth2: Authorization Code, Client Credentials, or PKCE, plus optional Auth URI Query Parameters (HTTP Request credentials). The refresh behavior lives in the runtime around that credential, not in a node toggle labeled “auto refresh.”

What extra Google config is actually required?

Google extra config is on the authorize URL, not on the Gmail node. If you never asked for offline access, Google returns an access token and no refresh token. n8n then has nothing to POST when that access token dies.

Google’s web-server docs: set access_type to offline so the authorization server returns a refresh token the first time the app exchanges the code. Default is online. prompt=consent forces the consent screen so you can mint a refresh token after an earlier online-only grant (offline access, prompt).

n8n’s generic OAuth2 field for that is Auth URI Query Parameters. The credential source placeholder is access_type=offline. For Google, send both:

access_type=offline&prompt=consent
Consent requestGoogle typically returnsUnattended refresh later?
No access_type (Google default online)Access token, often no refresh_tokenNo
access_type=offline on first consentAccess token + refresh tokenYes, until Google revokes the grant
access_type=offline on a later silent authAccess token, refresh token often omittedNo, unless you still have the first refresh token stored
access_type=offline&prompt=consentConsent screen; refresh token in the exchangeYes — this is why n8n’s Google credential hard-codes both

Procedure — generic Google OAuth2:

  1. Put access_type=offline&prompt=consent in Auth URI Query Parameters.
  2. Set Authentication to Body (Google’s token POST is form body; n8n’s Google credential hides this as body).
  3. Save. Sign in with Google again. Saving the field without reconnect does not rewrite oauthTokenData.
  4. If Google still withholds refresh_token because this user already consented, revoke the app under Google Account third-party access and Sign in once more with those query params on the request.

Use the current Google parameter names. Older Stack Overflow threads still say approval_prompt=force. Google’s current web-server docs document prompt=consent. n8n’s Google credential source uses prompt=consent. Prefer that string. If a non-Google vendor still documents approval_prompt, follow that vendor — do not mix Google’s current names onto their authorize URL.

You already didGoogle’s next code exchangeExtra config still needed?
First Sign in, no access_typeAccess token, no refresh tokenYes — add offline+consent, revoke, reconnect
First Sign in, access_type=offlineRefresh token includedNo, unless you later lose the stored blob
Second Sign in, offline, no promptRefresh token often omittedYes — prompt=consent or revoke third-party access
Query params edited, no new Sign inStored payload unchangedYes — reconnect is the write

Google also says, in the client-library notes, that the tokens event which carries refresh_token occurs on the first authorization with access_type=offline, and that you must re-authorize if you already granted the app without those constraints (offline access). n8n cannot invent a field Google did not return.

Reconnect is not extra config. Extra config is the authorize query string (and Body on generic). Reconnect is the write of a new oauthTokenData. Doing only the second step repeats the first-hour demo.

ActionChanges query params on next consent?Writes new oauthTokenData?
Edit Auth URI Query Parameters, SaveYes, for the next Sign inNo
Sign in / ReconnectUses whatever is saved nowYes
Change Client Secret onlyNoMaybe not — still the old refresh grant
Revoke app in Google Account, then Sign inForces a new consentYes, if offline is on that request

Built-in Google nodes do this without that text field. Generic HTTP Request against Google does not. Extra config is required there. It is not required because n8n “forgot to refresh.” It is required because Google never issued the grant.

Built-in Google OAuth2 vs generic OAuth2 — which needs extra fields?

Use the predefined Google credential on Google nodes when it exists. Use generic OAuth2 on HTTP Request when you are calling a Google URL the node does not cover — and then you own the query params.

n8n recommends predefined credential types on HTTP Request when the platform has one (HTTP Request credentials). For Google that split is documented as OAuth2 single-service, generic Google OAuth2 API, or service account (Google credentials).

CredentialAuth URI query paramsAuthenticationYou type extra Google config?
Built-in Google OAuth2 (googleOAuth2Api)Hidden default access_type=offline&prompt=consentHidden bodyNo — already in source
Generic OAuth2 APIEmpty string unless you fill itDefault HeaderYes — query params + usually Body
n8n Cloud Managed OAuth2 (listed Google nodes)n8n’s app, not your Cloud projectn8n-managedNo Cloud Console client; you still cannot edit those hidden fields
Google service accountN/A — JWT, no user refresh tokenN/ADifferent grant. Prefer it when the API allows it

Checklist:

  • Google node (Gmail, Sheets, Drive, …) → predefined Google credential, not a hand-rolled generic unless you have a reason
  • HTTP Request to a Google API → predefined Google type if n8n offers it on that node; otherwise generic with the query string above
  • Server-to-server Google work the node supports → service account, not a founder OAuth login
  • n8n Cloud Managed OAuth2 → fine for the listed nodes; do not expect a Google Cloud “Testing” toggle you do not own

n8n Cloud users who click Sign in with Google on the listed nodes are on Managed OAuth2. They do not paste access_type anywhere. Self-host and Custom OAuth2 still create a Web client in Google Cloud and paste the n8n redirect URI (OAuth2 single service). Extra config for refresh is a generic problem first.

Redirect URI is not refresh config, but a mismatch aborts consent before any token is stored. Copy OAuth Redirect URL from the n8n credential. Localhost is valid for Google during development (http://localhost:5678/rest/oauth2-credential/callback is the shape n8n documents). Production needs the public origin, exact protocol, and port. A working localhost Sign in does not prove the production redirect will mint a refresh token on the production host — reconnect there too.

HostRedirect you register in Google CloudRefresh-token note
Local n8nThe localhost callback n8n showsFine for proving extra query params
Self-host HTTPShttps://<your-domain>/rest/oauth2-credential/callbackReconnect on this host; do not copy oauthTokenData from laptop
n8n Cloud custom OAuthCloud callback n8n shows; authorized domain n8n.cloud on the consent screenStill no Auth URI field on built-in Google

Which grant types even issue a refresh token?

Authorization Code (and PKCE) can. Client Credentials usually must not. If you picked the wrong grant, n8n cannot “refresh” a token the vendor never sent. RFC 6749 §4.4.3 says a client-credentials access-token response should not include a refresh token. n8n’s generic Client Credentials path fetches a new access token from the token URL; it does not present a stored refresh token that does not exist.

Grant in the n8n credentialRefresh token expected?What n8n does when the access token is dead
Authorization CodeYes, if the vendor issued one (Google: offline access)grant_type=refresh_token
PKCESame family as Authorization Code; still needs vendor-issued refresh tokenSame refresh grant if stored
Client CredentialsNo, per RFC 6749Re-fetch access token with client id/secret

Google’s refresh call, when you do have the grant, is an HTTPS POST to https://oauth2.googleapis.com/token with client_id, client_secret, refresh_token, and grant_type=refresh_token in the body (Refresh an access token). n8n’s Google credential uses that token URL and Body authentication. Your generic credential must point at the same Access Token URL or the refresh POST goes to the wrong host.

Body field Google expectsn8n maps it from
grant_type=refresh_tokenRuntime, on refresh
refresh_tokenStored oauthTokenData
client_id / client_secretCredential fields, sent as Body on the Google type
Access Token URL hostHidden https://oauth2.googleapis.com/token on Google type; you type it on generic

If you pointed generic OAuth2’s Access Token URL at the authorize URL, Sign in can still complete in a browser and refresh will POST to the wrong endpoint. That is extra config of a different kind: the token URL is not the auth URL.

Decision list:

  1. Open the credential. Read Grant Type. If it is Client Credentials, stop looking for refresh_token.
  2. If it is Authorization Code against Google, look for Auth URI Query Parameters (generic) or use the built-in Google type.
  3. If the vendor is not Google, read their authorize-URL docs for the offline/refresh flag. Do not copy Google’s query string onto a token URL that is not Google’s.
  4. Do not invent a Refresh Token grant type in the graph. Store what the code exchange returned.

Wrong grant is the cheapest “refresh is broken” ticket. It is also the one a Cron cannot fix.

What does n8n write back after a refresh?

The whole new token payload, onto the same credential. Google’s refresh response is a new access token. It may or may not include a new refresh token. Some vendors rotate the refresh token on every use; Google’s documented refresh response is a new access token and does not require you to assume rotation. If n8n persists the response and a second worker writes an older blob afterward, the next refresh uses a discarded grant.

You do not configure a “write-back” toggle. The OAuth helper updates credential token data after refresh. Your job is: one writer for that credential, same encryption key, no copy of oauthTokenData living on the canvas.

After refreshPersist?Operator risk
New access_tokenYes — requiredIf this write fails, the next call still sends the dead bearer
New refresh_token if the vendor rotatedYes — requiredKeeping the first refresh token forever fails on rotating vendors
expires_inStored if the vendor sent itGeneric path still waits for HTTP status unless your n8n version added expiry-based refresh (hedge; do not assume)
Error JSON from the token URLNot a success writeinvalid_grant means re-consent, not retry the CRM

Checklist:

  • Queue workers share N8N_ENCRYPTION_KEY
  • You are not running two n8n instances against one Google client with racing Sign ins
  • You did not paste yesterday’s token JSON back into the credential “as a restore”
  • Token URL host matches the vendor (Google: https://oauth2.googleapis.com/token)

n8n cannot refresh with a refresh token it overwrote with an empty object. Write-back is the mechanism. Treat it like a database row, because it is one.

Google’s refresh response is a new access token; the refresh token field may be absent on that response. Keep the existing refresh token if the vendor did not rotate. Overwriting oauthTokenData with only access_token and dropping refresh_token is how a “successful” refresh orphans the grant. n8n’s helper is supposed to merge/persist the payload it got — do not hand-edit the blob to “clean it up.”

What should operators check before blaming n8n?

Check the grant, the consent URL, the stored payload, then the status code. In that order. “Reconnect” is step five, and only after you know why the first grant was incomplete.

Silent expiry ops (pause, one alert, Testing calendar) is the cousin post. This checklist is mechanism only.

  • Grant Type is Authorization Code or PKCE if you expect a refresh token
  • Google generic: Auth URI Query Parameters is access_type=offline&prompt=consent
  • Google generic: Authentication is Body, not the generic default Header
  • You clicked Sign in after those fields were saved
  • Built-in Google credential was used on Google nodes (hidden params already set)
  • Access Token URL is the vendor token endpoint, not the authorize URL
  • Dead-token status is 401 or whatever you put in Token Expired Status Code
  • Token URL errors are not invalid_grant (the refresh token is already dead)
  • Encryption key matches on main and workers
  • This is not Client Credentials dressed up as “OAuth refresh”
SymptomCheck firstNot first
Works for ~one hour, then 401 foreverRefresh token never issued (Google extra config)n8n “forgot” to schedule a refresh
401 then immediate retry successMechanism is workingMore nodes
200 + vendor error codeStatus trigger never firesToken Expired Status Code = 200
invalid_grant on token URLGrant revoked / Testing / unused windowRaising retries
Refresh works on one worker, not the otherEncryption key splitGoogle Cloud client id

Capture two URLs before you change Cloud Console branding. Operators mix them constantly.

URLHost (Google)What a failure here means
Resource APIgmail.googleapis.com, sheets.googleapis.com, …Access token rejected or the API call is wrong
Token / Access Token URLoauth2.googleapis.com/tokenRefresh grant rejected — missing token, Header vs Body, invalid_grant
Authorize URLaccounts.google.com/o/oauth2/v2/authConsent never completed; extra query params live here

Procedure — split the errors:

  1. Reproduce once. Note the HTTP status on the node (resource).
  2. If n8n attempted a refresh, note the token-URL status and body (invalid_grant vs network vs 401).
  3. If there was no token-URL call, the helper never thought the access token was expired — wrong status, or 200 body.
  4. If there was a token-URL call and it failed, extra Google config and reconnect are in play, not “add retries.”
  5. Only then open Google Cloud OAuth client settings (redirect URI, client secret).

If the last four Google-specific boxes are empty, extra config is needed. If they are full and the API returns 401 and retry still never happens, then look at persistence and workers. Do not start in the Google Cloud branding screen.

How do I prove the refresh token is there?

Prove it with behavior, not with a secret dump in Slack. n8n Cloud does not document a public “export oauthTokenData” button for operators, and you should not query credentials_entity as a habit. Google’s own OAuth guidance is to store refresh tokens in secure persistent storage and handle invalidation as a first-class path (OAuth best practices). n8n is that storage. Your proof is an unattended call after the access token is dead.

Google access tokens are short-lived. Google documents that they periodically expire and that offline access is how you mint a new one without a browser (offline access). Token responses often include expires_in on the order of an hour. Treat that as vendor metadata, not an n8n timer.

What you can observe without exporting secrets:

SurfaceSelf-hostn8n Cloud
Execution: 401 then retry 2xxYesYes
Credential modal: Sign in completedYesYes
Raw oauthTokenData keysSometimes visible to instance admins; treat as secretNot a documented operator export — do not file a support ticket asking them to paste it
Token URL error in the node outputYes, if the refresh ranYes, if the refresh ran
N8N_ENCRYPTION_KEY mismatchYour workersn8n operates the fleet; still do not clone credentials across instances

Procedure — staging proof:

  1. Use a non-production Google user or a throwaway Cloud project.
  2. Connect with the extra query params (or the built-in Google credential).
  3. Run one call. It should 2xx.
  4. Wait until the access token is dead (use the vendor expires_in you actually received; do not invent a studio SLA).
  5. Run again with nobody in a consent screen.
  6. Pass: 2xx, or a 401 followed by a retry 2xx in the same execution.
  7. Fail: 401 / invalid_token with no retry, or an immediate refreshToken is required / invalid_grant.
ResultMeaningNext
Unattended 2xx after access-token deathRefresh token is stored and the helper ranShip that credential shape to prod
401, then retry 2xxReactive refresh firedKeep tokenExpiredStatusCode matched
401, no retryNo refresh token, wrong status field, or skip-refresh option somewhereOperator checklist above
Token URL invalid_grantRefresh token revoked or never validRe-consent with offline+consent; do not hammer the resource API

Do not “prove” it by pasting refresh_token into a ticket. If your security review needs evidence, screenshot the execution that retried, not the secret.

Why Google Authentication should be Body on generic OAuth2

Because Google’s token endpoint expects the refresh POST as form fields in the body, and n8n’s first-party Google credential already chooses Body. Generic OAuth2 defaults to Header (HTTP Basic for the client id/secret). Some vendors want Header. Google’s documented refresh request puts client_id, refresh_token, and grant_type in the body (HTTP/REST refresh).

If you copied Google’s authorize and token URLs into generic OAuth2 and left Authentication on Header, the code exchange might still work on a good day and the refresh POST can fail in a way that looks like “n8n does not refresh.”

SettingGeneric OAuth2 defaultGoogle built-inUse for Google generic
AuthenticationHeaderBody (hidden)Body
Auth URI Query Parametersemptyaccess_type=offline&prompt=consentSame string
Authorization URLyou type ithttps://accounts.google.com/o/oauth2/v2/authThat URL
Access Token URLyou type ithttps://oauth2.googleapis.com/tokenThat URL

Checklist:

  • Authentication Body
  • Query parameters include access_type=offline
  • Query parameters include prompt=consent if this Google account already consented once
  • Redirect URI in Google Cloud matches the n8n credential OAuth Redirect URL exactly, including http vs https and port (redirect mismatch)
  • You reconnect after changing Authentication. The stored tokens were minted under the old POST shape.

Header vs Body is not a style choice. It is which RFC client-auth method the token URL implements. Match the vendor. For Google, match n8n’s own Google credential.

Failure mode: Sign in works, then the first unattended hour dies

The consent screen succeeded. The first Gmail or Sheets call succeeded. The next scheduled run returns 401 / invalid_token / refreshToken is required. Nothing in n8n “expired the credential.” The access token died on Google’s clock and there was no refresh token to mint another.

What it costs: a pipeline that looks finished in the demo, then drops the first overnight batch. You reconnect at 9am. It works until the next access-token lifetime. Teams call that flaky n8n. It is a missing offline grant.

What you observedLikely mechanism holeFix
First hour green, then 401, no retryNo refresh_token in oauthTokenDataExtra query params + reconnect (generic); or switch to built-in Google
401 then retry still 401Refresh POST failed (invalid_grant, wrong token URL, Header vs Body)Capture the token URL error, not only the resource 401
Dies every ~7 days, refresh did work until thenGoogle External + Testing grant life — n8n documents this as the app becoming unauthorized (troubleshooting)Publish / Internal app; ops detail on the expiry post
Dies after you added a workerStale oauthTokenData overwrite or split encryption keySame key; one writer
HTTP 200 with a vendor expiry codeReactive refresh never saw a 401Do not wait for n8n to read the body

What you do instead of a second reconnect:

  1. Stop using production as the experiment.
  2. Recreate the generic credential with offline+consent+Body, or use built-in Google.
  3. Prove unattended refresh in staging (previous section).
  4. Only then point production nodes at that credential.

A green Sign in is evidence you have an access token. It is not evidence you have a refresh token. Those are different rows in Google’s response.

Operators report a refreshToken is required error on Google nodes when the stored payload has no refresh token. Confirm the exact string on your n8n version; the meaning is the mechanism talking, not a Sheets outage. Extra config + reconnect, or switch to the built-in Google credential that already sends offline+consent. Do not raise HTTP Request timeout to “give refresh more time.” The helper either has a refresh token or it does not.

Queue workers and the encryption key — why refresh looks random

Refresh is a read-modify-write on an encrypted row. If worker A decrypts, refreshes, and writes, then worker B still holds a decrypted copy from boot with the old refresh token, B can write last and destroy A’s grant. If B cannot decrypt at all, B cannot refresh and the node errors on that worker only. Both look like “OAuth is flaky” in the execution list.

n8n’s documented rule: set N8N_ENCRYPTION_KEY for all workers in queue mode (custom encryption key). Newer encryption-key rotation (a second data key behind the instance key) is a feature-flagged deploy option (rotate encryption keys). If you have not turned that on, do not debug it. Debug the instance key first.

SetupRefresh write-backTypical fail
Single main process, key in ~/.n8nFineLosing that file = losing the ability to decrypt every credential
Queue mode, same N8N_ENCRYPTION_KEY on main + workersFineStill avoid two apps racing Sign in on one Google client
Queue mode, workers missing the env varWorkers cannot read or persist tokensFailures stick to whichever process drew the job
Two n8n environments, one Google OAuth client, both reconnectingGoogle may invalidate older refresh tokens (Google caps live refresh tokens per user per client — refresh token expiration)Staging Sign in kills production

Checklist:

  • N8N_ENCRYPTION_KEY is set explicitly in production, not “whatever first boot wrote”
  • Main, webhook, and workers print the same key configuration path (do not print the key)
  • Staging has its own Google OAuth client id
  • You did not clone the SQLite file onto a second host without the key

Random 401s that follow the worker name in the log are encryption or write-back. They are not Google extra config. Fix the process topology before you add prompt=consent a third time.

What not to build instead of credential refresh

Do not build a workflow that watches the clock, calls Google’s token URL with a refresh token you pasted into a Set node, and writes the new access token into headers. That duplicates n8n’s credential helper, stores a secret on the canvas, and still loses rotations. n8n already POSTs grant_type=refresh_token when the generic path sees the expired status.

WorkaroundWhy it shows upWhy you will regret it
Cron + HTTP Request to the token URL“n8n does not refresh on expires_in”You now own token persistence; the credential helper can fight you
Static data as the token cacheCopied from a forum threadNo encryption, no write-back into Credentials
Reconnect via the API on a scheduleGoogle Testing seven-day clockYou automated the symptom; see expiry-ops, do not put it here as a design
Continue on Fail on the OAuth nodeKeep the graph greenSwallows the 401 that is the refresh signal
Token Expired Status Code = 200“maybe it will refresh on success”You will refresh on every 200

Allowed exceptions are narrow:

  1. Vendor returns HTTP 200 with an expiry code — branch on the body and throw, so the helper or your error path can run. That is detection, not a second token store.
  2. Client Credentials — there is no refresh token. A new token fetch is the mechanism. Do not wrap it in Authorization Code fields.

If the vendor’s status never matches, change tokenExpiredStatusCode or throw on the body. If Google never sent refresh_token, change Auth URI Query Parameters and reconnect. Those are the two levers. A third workflow is not a third lever.

When should I hire vs DIY this?

DIY if you can finish the operator checklist on a staging credential in a sitting: correct grant, Google extra params if generic, Body for Google, reconnect, unattended proof after access-token death. Hire when the token lives across workers, multiple Google projects, or a vendor that does not return 401 — and you do not have time to capture raw responses.

The clock for a production rail is still access, staging, and hardening, not “add query params.” That clock is how long production automation takes. The bill is how much automation costs. This page does not replace either.

SituationDIYHire / audit
One Google node, built-in credential, single n8n processYes — Sign in, prove unattendedOnly if Testing vs Production still confuses the team
HTTP Request + generic Google OAuth2Yes if you will set query params + Body and reconnectIf you need a custom Google scope n8n’s generic list rejects — read generic Google scopes before you assume HTTP Request is free
Queue mode, several workers, one shared Google clientPossibleWorth a review the moment staging Sign in can kill prod
Vendor 200 + body expiry, or Header-only token URL quirksPossible if you can capture trafficHire if you cannot get a raw status/body
Service account is available and this is server-to-serverDIY the service accountDo not hire someone to “fix OAuth refresh” on a grant you should not be using

Skip this week if you only have Execute Workflow on an unpublished graph. Generic refresh and Google offline access are production-credential problems. They do not show up honestly on a manual run you baby-sit with a consent screen still in cache.

If you cannot name who owns the Google Cloud client and who owns the n8n encryption key, you are not ready to DIY the mechanism. You are ready to lose the grant twice.

One-week DIY slice, mechanism only:

  1. Inventory: Google node vs HTTP Request vs service account.
  2. Built-in Google where it exists. Generic: offline+consent+Body, then Sign in.
  3. Staging unattended proof after expires_in.
  4. Encryption key check if you have more than one process.
  5. Stop. Alerts, pause, and Testing calendars are the other post.

That is a credential sitting, not a product sprint. If step 3 fails and you cannot capture token-URL vs resource-URL errors, that is the hire signal — not “add AI to classify 401s.”

FAQ

How does n8n refresh OAuth access tokens?

It reads oauthTokenData from the encrypted credential, calls the token URL with grant_type=refresh_token when the resource API returns tokenExpiredStatusCode (default 401 on generic OAuth2), writes the new payload back, and retries. Google must have issued a refresh token first, which requires access_type=offline on the authorize request. Built-in Google OAuth2 already sends access_type=offline&prompt=consent; generic OAuth2 needs those Auth URI Query Parameters and a reconnect.

How do I measure whether does n8n refresh OAuth access tokens is working?

Measure an unattended call after the access token is dead: a 2xx, or a 401 followed by a retry 2xx in the same execution. Use the vendor expires_in you actually received as the wait, not a made-up studio interval. A green Sign in only proves an access token. A token-URL invalid_grant means the refresh grant failed, which is a different measurement than a resource 401.

What usually fails first when teams try this?

Google generic OAuth2 without access_type=offline (and without prompt=consent on a re-auth), so no refresh token is stored. Next is a vendor that expires tokens with 403 or a 200 body while n8n still waits for 401. Third is queue workers missing N8N_ENCRYPTION_KEY, so write-back never lands. Reconnecting without fixing those three repeats the same hour of success.

How long does this take to show results?

The extra Google fields take minutes. Proof takes one access-token lifetime — often about an hour on Google token responses, but use the expires_in you got. If you only reconnect and leave, you will think it is fixed until the next expiry. A staging credential that survives that wait is the result. Publishing a Google Testing app so the refresh grant itself lasts is a separate, longer Cloud Console change.

What should I skip if I only have a week?

Skip building a Cron that hits the token URL yourself, and skip dumping oauthTokenData onto the graph. Skip production Sign ins while generic Google still has Header auth and empty query params. Do pick built-in Google where it exists, or set offline+consent+Body, reconnect, and prove one unattended retry. Leave Testing-vs-Production and alert design to the expiry-ops post if that is all the week has room for after proof.

When is this not worth doing yet?

When the API key or service account is the real grant and you are forcing user OAuth anyway. When the workflow is still unpublished and every run is a manual Execute with a fresh consent. When you cannot capture a dead-token HTTP status. Fix hosting and a single staging credential first; the mechanism does not exist until n8n can persist oauthTokenData and the vendor can return a refresh token.

CTA

Get the refresh token issued, stored, and proven on a 401 retry — extra Google query params on generic OAuth2, then reconnect. Do not schedule a second graph to impersonate the credential helper.

Keep the handbook open for the rest of the spine. For a credential-mechanism review on your n8n estate, use automation or book the audit.

FAQ

What questions does this article answer?

How does n8n refresh OAuth access tokens?
It reads `oauthTokenData` from the encrypted credential, calls the token URL with `grant_type=refresh_token` when the resource API returns `tokenExpiredStatusCode` (default 401 on generic OAuth2), writes the new payload back, and retries. Google must have issued a refresh token first, which requires `access_type=offline` on the authorize request. Built-in Google OAuth2 already sends `access_type=offline&prompt=consent`; generic OAuth2 needs those Auth URI Query Parameters and a reconnect.
How do I measure whether does n8n refresh OAuth access tokens is working?
Measure an unattended call after the access token is dead: a 2xx, or a 401 followed by a retry 2xx in the same execution. Use the vendor `expires_in` you actually received as the wait, not a made-up studio interval. A green Sign in only proves an access token. A token-URL `invalid_grant` means the refresh grant failed, which is a different measurement than a resource 401.
What usually fails first when teams try this?
Google generic OAuth2 without `access_type=offline` (and without `prompt=consent` on a re-auth), so no refresh token is stored. Next is a vendor that expires tokens with 403 or a 200 body while n8n still waits for 401. Third is queue workers missing `N8N_ENCRYPTION_KEY`, so write-back never lands. Reconnecting without fixing those three repeats the same hour of success.
How long does this take to show results?
The extra Google fields take minutes. Proof takes one access-token lifetime — often about an hour on Google token responses, but use the `expires_in` you got. If you only reconnect and leave, you will think it is fixed until the next expiry. A staging credential that survives that wait is the result. Publishing a Google Testing app so the *refresh* grant itself lasts is a separate, longer Cloud Console change.
What should I skip if I only have a week?
Skip building a Cron that hits the token URL yourself, and skip dumping `oauthTokenData` onto the graph. Skip production Sign ins while generic Google still has Header auth and empty query params. Do pick built-in Google where it exists, or set offline+consent+Body, reconnect, and prove one unattended retry. Leave Testing-vs-Production and alert design to the expiry-ops post if that is all the week has room for after proof.
When is this not worth doing yet?
When the API key or service account is the real grant and you are forcing user OAuth anyway. When the workflow is still unpublished and every run is a manual Execute with a fresh consent. When you cannot capture a dead-token HTTP status. Fix hosting and a single staging credential first; the mechanism does not exist until n8n can persist `oauthTokenData` and the vendor can return a refresh token.
Sources

Last reviewed

More from this lane

Automation

All →
Book the audit