oauth2.googleapis.com/tokenfailing
17 days ago
Since approximately June 19, 2026 (~17:00 UTC), our backend service can no longer complete outbound HTTPS requests to https://oauth2.googleapis.com/token. Every request fails at the transport layer with:
FetchError: Invalid response body while trying to fetch
https://oauth2.googleapis.com/token: Premature close
errorCode: 'ERR_STREAM_PREMATURE_CLOSE'
The TCP/TLS connection establishes and the response begins, then the response body stream is cut before completion. This breaks all Firebase Admin / Google OAuth2 token minting — user signup/login (custom tokens), FCM push notifications, and Gmail API sync — because every one of those needs a freshly-exchanged access token from this endpoint. User-facing impact: customers cannot sign up or sign in.
This is not an application bug. We need help investigating the Railway → Google network path (routing / MTU / peering / egress IP reputation) for this project.
1 Replies
17 days ago
This thread has been opened as a bounty so the community can help solve it.
Status changed to Open Railway • 17 days ago
17 days ago
Update — solved. It was NOT an egress/IP block; Railway's network is fine.
Following up on my post above (where I suspected Google was blocking Railway's shared US-East egress IPs) — that diagnosis was wrong, and I want to correct the record in case it saves anyone else hitting Premature close on googleapis.com.
What it actually was: an HTTP-client bug, not the network. firebase-admin mints its OAuth access token via node-fetch (through google-auth-library), and from my Railway container that call fails with ERR_STREAM_PREMATURE_CLOSE on ~100% of attempts. But Node's global fetch (undici) hits the exact same endpoint — same container, same egress IP, same moment — and succeeds.
How I proved it: I deployed a tiny /debug/egress endpoint that, from inside the container, reports the outbound IP and POSTs to the token endpoint:
egress IP: 162.220.x.x (Railway shared)
global fetch -> https://oauth2.googleapis.com/token : HTTP 400 ✅ reachableA 400 means we reached Google fine (it just rejected the empty test body). So the egress was never blocked. Every "fix" I tried — static IPs, IPv4-first DNS, region change (US-East → US-West), even a 2-day code rollback — failed, because they were all aimed at a network problem that didn't exist.
The fix: mint the token with global fetch instead of node-fetch. For firebase-admin you can't just swap in a custom credential — Cloud Firestore requires a real cert() credential (instanceof ServiceAccountCredential, it reads .privateKey/.clientEmail off it), so I kept cert() and overrode only its getAccessToken():
import crypto from 'node:crypto';
import { cert } from 'firebase-admin/app';
function credentialWithGlobalFetch(serviceAccount) {
const credential = cert(serviceAccount); // keep cert() so Firestore/Storage still work
const { client_email, private_key, token_uri = 'https://oauth2.googleapis.com/token' } = serviceAccount;
const b64 = (s) => Buffer.from(s).toString('base64url');
credential.getAccessToken = async () => {
const now = Math.floor(Date.now() / 1000);
const scope = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/firebase',
'https://www.googleapis.com/auth/identitytoolkit',
'https://www.googleapis.com/auth/datastore',
'https://www.googleapis.com/auth/devstorage.read_write',
'https://www.googleapis.com/auth/userinfo.email',
].join(' ');
const head = b64(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const body = b64(JSON.stringify({ iss: client_email, scope, aud: token_uri, iat: now, exp: now + 3600 }));
const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), private_key).toString('base64url');
const res = await fetch(token_uri, { // <-- global fetch (undici), NOT node-fetch
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${head}.${body}.${sig}`,
}),
});
if (!res.ok) throw new Error(`token mint failed: ${res.status} ${await res.text()}`);
const j = await res.json();
return { access_token: j.access_token, expires_in: j.expires_in };
};
return credential;
}
// initializeApp({ credential: credentialWithGlobalFetch(serviceAccount), ... })Signup / login / FCM push all came back instantly.
This is the same family as the other recent Premature close thread (openai SDK / undici failing, axios working): one HTTP client chokes on a given endpoint while another handles it fine. If you see Premature close on any googleapis.com / openai.com call, try a different HTTP client before assuming it's the network.
Best guess on why it started suddenly: node:20-bookworm-slim is a moving tag, so any rebuild picks up the latest Node 20.x — a patch that tweaked HTTP/1.1 connection handling would break node-fetch (which rides Node's http module) while leaving undici untouched. Would love a pointer if anyone knows the exact Node/undici/node-fetch interaction.
Thanks to everyone who pushed back on the network theory — that's what got me to actually probe it instead of chasing IPs.
Status changed to Solved 0x5b62656e5d • 17 days ago