The Rarely Known Fact: Using Google Service Account for M2M JWT Authentication and Authorization

| March 20, 2025

Use a Google service account to sign and verify M2M JWTs with custom claims, with a working NestJS example.

This post contains affiliate links for tools I use in production. If you buy through them I earn a commission at no extra cost to you. Recommendations are based on my own experience.

TL;DR

Google service accounts can handle machine-to-machine (M2M) authentication without an end user in the loop. This post shows how to generate and verify JSON Web Tokens (JWTs) with custom claims (for example, a role claim) in NestJS, using Google’s key management instead of rolling your own.

Related repos you can clone and run: service-b service-c

Table of Contents

Overview of M2M Authentication with Google Service Accounts

In a microservices architecture, services need to authenticate each other without a human present. Google service accounts solve this by issuing JWTs with standard and custom claims. The issuing service signs the token with a private key, and the receiving service verifies it against Google’s published public certificates. Neither side has to manage a shared secret.

Setting Up the Google Service Account

  1. Create a service account.

    • Go to the Google Cloud Console.
    • Navigate to IAM & Admin > Service Accounts.
    • Create a new service account and download its JSON key file (for example, service-account-key.json).
  2. Understand the key file.

    • private_key: signs the JWT.
    • client_email: becomes the issuer (iss) in the JWT payload.
    • private_key_id: goes in the JWT header (kid) so the verifier knows which public key to fetch.

Generating a JWT in Service B

Service B generates a JWT with a custom role claim, signed with the service account’s private key:

import * as jwt from 'jsonwebtoken';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AuthService {
constructor(private configService: ConfigService) {}
generateToken(role: string): string {
const privateKey = this.configService.get<string>('private_key');
const clientEmail = this.configService.get<string>('client_email');
const privateKeyId = this.configService.get<string>('private_key_id');
const payload = {
iss: clientEmail,
aud: 'http://localhost:3001', // Service C's endpoint
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
role, // custom claim for authorization
};
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
keyid: privateKeyId,
});
}
}

Key points:

  • The payload combines standard claims (iss, aud, iat, exp) with a custom role claim.
  • Signing uses RS256 with the service account’s private key. The kid header tells the verifier which public key to use, so key rotation does not break existing tokens.

Verifying the JWT in Service C

Service C fetches Google’s public certificates and verifies the token against them, then checks the custom claim. The 2025 version of this example used a raw https.get call with a manual JSON buffer. Since NestJS 12 requires Node 20+, the built-in fetch API is the simpler choice today:

import * as jwt from 'jsonwebtoken';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
private serviceAccountEmail = '[email protected]';
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) return false;
const certs = await this.getCerts();
const decodedHeader = jwt.decode(token, { complete: true })?.header;
const publicKey = certs[decodedHeader?.kid ?? ''];
if (!publicKey) return false;
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'http://localhost:3001',
issuer: this.serviceAccountEmail,
}) as { role?: string };
return decoded.role === 'admin'; // grant access only for the admin role
}
private async getCerts(): Promise<Record<string, string>> {
const res = await fetch(
`https://www.googleapis.com/service_accounts/v1/metadata/x509/${this.serviceAccountEmail}`,
);
if (!res.ok) {
throw new Error(`Failed to fetch Google certs: ${res.status}`);
}
return res.json();
}
}

Highlights:

  • The public keys come straight from Google’s endpoint, no local cert file to maintain.
  • Verification checks the signature, audience, and issuer in one call.
  • Access is granted only when the decoded token’s role is admin.

Debugging this in production

JWT verification failures rarely reproduce on demand: an expired cert cache, a clock skew of a few seconds between hosts, or a kid that no longer matches Google’s rotation all show up as an intermittent 401 with no request context in your logs. An error tracker such as Sentry attaches the request, the user and the release to every exception, and its free tier covers a side project.

Benefits and Conclusion

Performance and Security Benefits

  • Efficiency: Google rotates keys and distributes certificates for you, so there’s no manual key management. Verification is fast once the certs are fetched.
  • Security: The private key never leaves Service B. Verification against Google’s public certificates guarantees the token wasn’t tampered with.
  • Scalability: Centralized IAM management keeps secure communication simple as you add more services.

Conclusion

Google service accounts give you a secure, scalable way to do M2M JWT authentication without managing shared secrets. Generate the JWT with custom claims in one service, verify it in another, and you get strong auth with very little added complexity. The NestJS examples above are a practical starting point for wiring this into your own services.

Next steps: scaling to production

If you take this into production, these are the pieces I would add first.

  • Clerk Clerk provides drop-in authentication and user management components. Hosted auth saves the login, session and org code you would otherwise maintain.
  • Supabase Supabase is a hosted Postgres platform with authentication and storage built in. Postgres with row-level security, so the data layer is ready for multi-tenant apps.
  • Sentry Sentry captures errors and performance traces from production applications. Errors and slow transactions from real users, with source maps, before customers report them.

Production-ready Astro + TanStack architecture

Get the architecture cheat sheet and join the waitlist for the Astro SaaS boilerplate.