The three GCP credentials, and which question each one answers
I reached for OAuth 2.0 every time GCP asked me to authenticate. It worked great for login. Then my backend needed to read a Google Calendar on a schedule, no user in sight, and OAuth simply refused. I'd picked the wrong credential and didn't know there was a right one.
GCP hands you three, and they look interchangeable until one of them won't work. The fix is to stop asking "which credential" and start asking "who is making the request."
| Credential | Who's asking | What it's for |
|---|---|---|
| OAuth 2.0 Client ID | a real user | let people sign into your app |
| Service Account | your backend | machine-to-machine, no user involved |
| API Key | nobody in particular | hit a public Google API |
One line to remember:
- A real person has to click "allow" → OAuth 2.0 Client ID.
- Your server acts on its own → Service Account.
- You just need into a public API like Maps → API Key.
OAuth 2.0 Client ID: a person has to be there
I hit this building a Google sign-in (the One Tap popup, the kind Medium uses, officially Google Identity Services). It wanted an OAuth 2.0 Client ID. That confused me, because I was already doing Google login through Firebase Auth. Were they different systems?
They aren't. Both are OAuth 2.0 Client IDs underneath. They differ only in plumbing, and both need the user to walk through the authorization flow. You can even chain them: let One Tap collect the credential, then hand it to Firebase Auth to manage:
// the user actively logs in
google.accounts.id.initialize({
client_id: "YOUR-WEB-CLIENT-ID.apps.googleusercontent.com",
callback: (response) => {
// response.credential is the user's JWT, hand it straight to Firebase
firebase.auth().signInWithCredential(
firebase.auth.GoogleAuthProvider.credential(response.credential)
)
},
})The defining trait: someone real must authorize. No user, no OAuth. Which is exactly why my headless backend couldn't use it.
To create one: GCP Console → APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID → pick the application type → set the authorized redirect URIs.
Service Account: a virtual employee
This is the credential for machine-to-machine work, no human in the loop. A Service Account (SA) is a virtual employee: it has an identity, and you grant it permissions.
Here's the wall I hit. I tried OAuth 2.0 to let my backend read Google Calendar. It didn't work, because OAuth reads the logged-in user's own calendar, and I had no logged-in user. I wanted my system to read one specific calendar, unattended. That's the Service Account's whole job.
The flow that finally worked:
- Enable the Calendar API in GCP. Opening the main door.
- Create a Service Account. Deciding who may walk through it.
- Add the SA's email to that calendar's share list. This is the step everyone forgets.
- Download the SA's JSON key, pull the values into env vars, build the auth client.
Step 3 is the one that bites. I skipped it the first time and got permission denied from the API, over and over, while everything else looked correct.
function getGoogleAuth() {
// these three come out of the downloaded SA JSON
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL
const privateKey = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n') // mind the newlines
const projectId = process.env.GOOGLE_PROJECT_ID
return new google.auth.GoogleAuth({
credentials: { client_email: clientEmail, private_key: privateKey, project_id: projectId },
scopes: ['https://www.googleapis.com/auth/calendar'],
})
}
export function getCalendar() {
return google.calendar({ version: 'v3', auth: getGoogleAuth() })
}ADC: the Service Account you didn't create
Application Default Credentials (ADC) is the shortcut that makes Service Accounts feel effortless inside GCP. Deploy on Cloud Run and it hands your app a default SA automatically, like PROJECT_NUMBER-compute@developer.gserviceaccount.com, already able to write logs and metrics. Nothing sensitive, so no key to manage.
// inside GCP, ADC finds the credentials for you
const auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
})ADC hunts for credentials in order: the GOOGLE_APPLICATION_CREDENTIALS env var, then your gcloud CLI login, then the running service's default SA.
But there's a trapdoor, and it's the same Calendar story. Calendar is a Google Workspace API, not a GCP IAM resource. ADC won't auto-discover a credential for it. You still have to specify the SA explicitly, and the calendar still has to share itself with that SA. The seamless path stops at the edge of GCP's own services.
API Key: a permission slip for public stuff
The last one is the simplest. An API Key is for public Google services that don't touch anyone's private data, like Maps.
// no key = 403 Forbidden
fetch(`https://maps.googleapis.com/maps/api/geocode/json?address=Taipei`)
// with key = works, within your free quota
fetch(`https://maps.googleapis.com/maps/api/geocode/json?address=Taipei&key=${API_KEY}`)The key isn't really about identity. It's about abuse: rate limits, usage tracking, billing, domain allowlists. Same idea as needing an API key on AWS API Gateway. You're not proving who you are, you're getting a metered ticket in.
Putting it together
A typical full-stack flow uses all three:
- Frontend logs the user in with OAuth 2.0.
- Frontend passes the user's token to the backend to verify identity.
- Backend uses a Service Account to talk to Google services.
- Need a public service like Maps? Add an API Key.
And the decision collapses to one question, who's asking:
if (aUserIsInvolved) {
return userMustActivelyAllow ? 'OAuth 2.0 Client ID' : 'Service Account'
}
return 'API Key'Once you read GCP Credentials as "who is making this request" instead of "which key do I paste," they stop blurring together. Don't be me, reaching for OAuth on everything and wondering why the backend just sat there.
Aside: AWS makes you spell it out
AWS and GCP wire up service permissions from opposite ends.
On AWS, nothing has access until you grant it. Spin up a Lambda and it comes with its own role that spells out exactly what it may do, like CreateLogStream and PutLogEvents. A second Lambda gets its own separate role, even if the permissions are identical. You are always the one saying "this function may touch that service." More setup, more control.
GCP does the opposite. The access is mostly already there. Every Cloud Run service shares one default Service Account that covers the basics out of the box. You wire up nothing, it just works. Less control, no friction.
That convenience is the catch. You get used to GCP handling permissions for you, so you assume it will cover the Calendar read too. It will not. Calendar is a Workspace API, outside GCP's border, and you are right back to doing it by hand: name the Service Account, share the calendar with it.