TL;DR - Verified Google tokens server-side, created Cognito users via admin APIs with
email_verified: true, generated deterministic passwords from user IDs + a server secret, and bypassed Cognito's hosted UI entirely. Works for signup and sign-in. Not for everyone, but perfect when you can't use Cognito's standard federation.
The Constraint: No Console Access, No Hosted UI
I inherited a broken authentication system. No AWS console access. No ability to change the login UI. And a hard requirement: Google Sign-In had to work. Here's how I made Cognito do something it wasn't designed to do.
The stack was NestJS on the backend with AWS Cognito as the user store. Straightforward enough — except for one constraint that made everything harder than it needed to be: I didn't have direct access to the AWS console. Every deployment, every config change, every "let me just quickly check this setting" moment required me to sit down with my manager and do it together. No flying solo.
That constraint alone changes how you debug. You can't just poke around. You have to be sure before you touch anything.
Why Cognito Doesn't Want You to Do This
The authentication itself wasn't the hard part. The hard part arrived when we got to Google Sign-In.
The organisation wanted their own UI — their own buttons, their own design, their own flow. No redirects to some AWS-hosted page, no Cognito's default login screen. Just a clean "Sign in with Google" button sitting inside our own frontend.
The problem is Cognito doesn't really want you to do that. If you want Google as a sign-in provider in Cognito, the standard path is to configure Google as a federated identity provider through the AWS console and use Cognito's hosted UI to handle the OAuth redirect. Both of those were either unavailable or unacceptable for us.
So I had a user store I couldn't fully configure, a UI I couldn't change, and a Google Sign-In requirement that sat right in the middle of both.
I started thinking about what I actually controlled — and that's when the idea came together.
How I Hacked Cognito to Support Google Sign-In Without the Hosted UI
While digging into Google's token verification, I noticed something useful — when you verify a Google ID token, you get back real, trusted data. Email, name, profile picture, Google's own user ID. All of it already verified by Google on their end.
Around the same time I was looking at Cognito's admin commands — a set of server-side APIs that let your backend manipulate the Cognito user pool directly, without any user-facing flow. No hosted UI, no redirects, no OTP. Just your server talking directly to Cognito.
That's when it clicked.
If the Google token is coming from the user's device and Google has already verified it — why am I trying to verify the user again? The verification already happened. I don't need to send an OTP, I don't need email confirmation, I don't need any of that ceremony. Google already did the hard part. All I need to do is trust that token, extract the user's details, and get them into Cognito.
So that's exactly what I did — verify the Google token server-side using google-auth-library, extract the user's details, and use Cognito's admin commands to create the user directly in the user pool.
But Cognito needs a password for every user. Even if the user will never type it.
So I made one up — deterministically. Using the user's Cognito ID, a hashing function, and a secret key that lives only on the server. The same inputs always produce the same password, which means when the user signs in again later I can regenerate it on the fly. From Cognito's perspective it's just a normal username/password user. From the user's perspective they just clicked "Sign in with Google." Neither side needed to know about the other.
Standard Cognito + Google:
User → Your UI → Cognito Hosted UI → Google → Cognito → Your App
My approach:
User → Your UI → Google → Your Backend → Cognito Admin API
The Code: Five Steps to a Working Hack
Let me walk through the actual code. The whole flow lives across three functions — the controller endpoint, the Google token verifier, and the password generator.
The Controller — One Endpoint, Two Flows
The signup endpoint handles both Google and normal signup from the same route. The split happens at the top — if a googleToken is present in the request body, it takes the Google path. Otherwise it falls through to the normal signup flow.
typescript
@Post('signup')
@UseInterceptors(FileInterceptor('client_logo'))
async signup(
@UploadedFile() clientLogoFile: Express.Multer.File,
@Body('googleToken') googleToken: string,
@Body('request_create_client_dto') requestCreateClientDto: string,
@Body('request_signup_user_dto') requestSignupUserDto?: string,
) {
const createClientDto = JSON.parse(requestCreateClientDto);
// Google signup flow
if (googleToken) {
return await this.authService.googleSignupCompany(
googleToken,
createClientDto,
clientLogoFile,
);
}
// Normal signup flow
if (!requestSignupUserDto) {
throw new BadRequestException('Signup user data is required');
}
const signupUserDto = JSON.parse(requestSignupUserDto);
return await this.authService.signupWithClientAndUser(
createClientDto,
signupUserDto,
clientLogoFile,
);
}
Clean separation — no Google-specific logic leaks into the normal flow and vice versa.
Step 1 — Verify the Google Token Server-Side
The first thing we do on the server is verify the incoming Google ID token using google-auth-library. This is non-negotiable — you never trust a token that hasn't been verified server-side.
typescript
async verifyGoogleToken(googleToken: string) {
try {
const ticket = await this.googleClient.verifyIdToken({
idToken: googleToken,
audience: process.env.GOOGLE_CLIENT_ID,
});
return ticket.getPayload();
} catch (error) {
throw new BadRequestException('Invalid Google token');
}
}
verifyIdToken checks the token signature against Google's public keys, validates the audience (your client ID), and confirms it hasn't expired. If any of that fails, it throws. If it passes, getPayload() gives you everything you need — email, given_name, family_name, sub (Google's unique user ID), and more.
The key insight here: Google already verified this user. Email is confirmed, identity is confirmed. We don't need to send an OTP or a verification email — that ceremony has already happened on Google's end.
Step 2 — Check for Duplicates Before Touching Cognito
Before creating anything, we run these checks in order — database first, Cognito second. This prevents ghost users from being created if something fails midway.
typescript
// Check if email already exists in your DB
const existingUser = await this.prisma.userMaster.findFirst({
where: { user_email: email },
});
if (existingUser) {
throw new HttpException('Email is already in use.', 409);
}
// Check if user already exists in Cognito
const existsInCognito = await this.userExistsInCognito(email);
if (existsInCognito) {
throw new HttpException('User already exists in Cognito.', 409);
}
This also handles the case where someone already signed up with a normal email/password and is now trying to use Google Sign-In with the same email. They get a 409 before anything is created.
Step 3 — Create the Cognito User via Admin Commands
Now the interesting part. Instead of any user-facing flow, we use AdminCreateUserCommand to insert the user directly into the user pool from our server.
typescript
const createUserResp = await this.cognitoClient.send(
new AdminCreateUserCommand({
UserPoolId: this.userPoolId,
Username: email,
UserAttributes: [
{ Name: 'email', Value: email },
{ Name: 'email_verified', Value: 'true' },
{ Name: 'given_name', Value: given_name },
{ Name: 'family_name', Value: family_name },
],
MessageAction: 'SUPPRESS',
}),
);
email_verified: 'true' — we set this explicitly because Google already verified the email. Cognito doesn't need to send its own verification email, and with MessageAction: 'SUPPRESS' we make sure it doesn't try to.
MessageAction: 'SUPPRESS' — without this, Cognito sends a welcome email with a temporary password to the user. We don't want that. The user signed in with Google — they shouldn't be receiving a random Cognito email they don't understand.
Step 4 — Generate a Deterministic Password
Cognito needs every user to have a password. The user will never type it, but it has to exist. Here's how we create one:
typescript
private generateDeterministicPassword(cognitoSub: string): string {
const secret = process.env.INTERNAL_SALT_PASSWORD_KEY;
const hash = crypto
.createHmac('sha256', secret)
.update(cognitoSub)
.digest('hex');
return 'Pw#' + hash.slice(0, 20);
}
The logic is simple but the properties matter:
Unique per user — cognitoSub is Cognito's own unique identifier for that user, so every generated password is different
Deterministic — the same inputs always produce the same output, so we can regenerate it during sign-in without storing it anywhere
Server-only — the secret key never leaves the server, so even if someone gets the Cognito user ID, they can't reverse the password without the secret
Meets Cognito's requirements — the Pw# prefix ensures uppercase, lowercase, number, and special character requirements are always satisfied.
Then we set it permanently using AdminSetUserPasswordCommand:
typescript
await this.cognitoClient.send(
new AdminSetUserPasswordCommand({
UserPoolId: this.userPoolId,
Username: email,
Password: generatedPassword,
Permanent: true,
}),
);
Permanent: true is important — without it Cognito treats the password as temporary and forces a reset on first sign-in, which would break our flow entirely.
Step 5 — Store the User in Your Database
Finally, we create the user record in our own database with one important field — from: 'GoogleSignUp'. This is the flag that drives all the routing logic later.
typescript
const user = await this.prisma.userMaster.create({
data: {
cognito_id: cognitoId,
user_email: email,
first_name: given_name,
last_name: family_name,
is_active: true,
from: 'GoogleSignUp', // this flag matters
created_at: new Date(),
},
});
Any future request that touches auth — sign-in, password reset, forgot password — checks this flag first and routes accordingly.
The Sign-In Flow:-
Signup was the hard part. Sign-in is elegant by comparison — because we already did the heavy lifting.
When a returning Google user hits the sign-in endpoint, the flow mirrors signup but in reverse:
typescript
async googleSignin(googleToken: string, res: ExpressResponse): Promise<any> {
if (!googleToken) throw new BadRequestException('googleToken is required');
// Step 1 — verify the Google token again
const payload: any = await this.verifyGoogleToken(googleToken);
const email = payload?.email;
if (!email) throw new BadRequestException('Email not found in Google token');
// Step 2 — find the user in our DB
const user = await this.prisma.userMaster.findFirst({
where: { user_email: email },
});
if (!user) throw new HttpException('User not found. Please sign up first.', 404);
// Step 3 — regenerate the same deterministic password
const password = this.generateDeterministicPassword(user.cognito_id);
const secretHash = this.computeSecretHash(email);
// Step 4 — authenticate directly via admin command
const auth = await this.cognitoClient.send(
new AdminInitiateAuthCommand({
UserPoolId: this.userPoolId,
ClientId: this.clientId,
AuthFlow: 'ADMIN_NO_SRP_AUTH',
AuthParameters: {
USERNAME: email,
PASSWORD: password,
SECRET_HASH: secretHash,
},
}),
);
const result = auth.AuthenticationResult;
// set httpOnly cookies and return tokens...
}
ADMIN_NO_SRP_AUTH skips the Secure Remote Password protocol — a client-side password hashing challenge Cognito normally uses. Since we're authenticating server-side with a programmatically generated password, we don't need it and bypassing it simplifies the flow.
The key is Step 3 — we never stored the password anywhere. We just regenerate it from the same inputs: cognito_id + secret key + sha256. Same function, same output. Cognito validates it and hands back an access token and refresh token like any normal auth flow.
We set tokens as httpOnly cookies so they're never accessible from JavaScript — a small but important security detail on top of an already unconventional auth pattern.
What Could Break (And How We Handled It):
This is where most tutorials stop — right after the happy path works. But a production auth system lives or dies by its edge cases. Here's what we thought through.
Forgot password for a Google user
This one is a hard block — and intentionally so. If a user signed up via Google, there is no password for them to reset. So we check the from flag before even touching Cognito:
async forgotPassword(email: string): Promise<string> {
const user = await this.prisma.userMaster.findFirst({
where: { user_email: email },
});
if (user.from === 'GoogleSignUp') {
throw new HttpException(
'Password reset is not available for accounts created using Google Sign-In. Please sign in using Google.',
HttpStatus.BAD_REQUEST,
);
}
// normal forgot password flow continues...
}
The error message matters here — don't just throw a generic 400. Tell the user exactly what to do. "Please sign in using Google" is actionable. "Bad request" is not.
Same email, different signup method
If someone already has a normal email/password account and tries to sign up again via Google with the same email — they hit the 409 check before anything is created. The response tells them an account already exists and to use their password instead. If they've forgotten it, the normal forgot password flow is available to them.
The reverse is also handled — a Google user trying to use the normal sign-in form gets blocked at the from flag check before any Cognito call is made.
What if the Google token is invalid or expired
verifyIdToken from google-auth-library handles this entirely — it validates the signature against Google's public keys and checks expiry. If anything fails it throws, we catch it and return a clean 400 Invalid Google token. No Cognito calls are made if token verification fails.
What if the secret key is rotated:
This is an important risk to acknowledge. If INTERNAL_SALT_PASSWORD_KEY changes, every Google user's regenerated password would be different from what's stored in Cognito — meaning every Google sign-in would fail with a NotAuthorizedException.
The recovery path is a migration script — fetch all users from the database where from = 'GoogleSignUp', regenerate their password using the new secret, and update Cognito via AdminSetUserPasswordCommand. It's a clean operation since the formula is deterministic. But it needs to be planned and communicated before any secret rotation happens — not discovered after.
When you should use this pattern:
Use this when all three of these are true:
You want Google Sign-In as a user-facing experience
You're using Cognito as your user store
You can't or don't want to use Cognito's hosted UI or federated identity provider setup
If you have full AWS console access and are comfortable with Cognito's hosted UI, the standard federation approach is simpler and you should use that. This pattern exists specifically for the constraints we were working under — and it turns out those constraints are more common than you'd think.
What I'd Do Differently
Store auth_provider as an enum, not a string. from: 'GoogleSignUp' works but a typo anywhere silently breaks routing. An enum gives you type safety for free.
Plan secret key rotation before going live. We haven't formally discussed this yet — but it needs a runbook before production. The migration script is straightforward, the problem is being caught off guard by it.
Document it internally the moment it works. This pattern isn't obvious. The next developer touching auth will spend real time figuring out why Cognito users have a programmatically generated password if there's no explanation anywhere. Write it down while it's fresh.
This article was originally published by DEV Community and written by Shubham Sharma.
Read original article on DEV Community