I wanted people to sign in to a small Next.js app with their Google account instead of a username and password. I already had my own session cookie, so I didn’t want to pull in a whole auth library and replace it. This post is the OpenID Connect authorization code flow with PKCE, written by hand with two route handlers and the jose package. If you want the theory first, see OAuth2 Authorization Code Flow, and for the Microsoft equivalent see Adding Microsoft Identity Platform To An Existing App.
1. Create a Google Cloud project
This part is all UI, there is no CLI or API for creating a standard web OAuth client.
- Open the Credentials page in Google Cloud Console.
- Click the project picker at the top left, then New project, and name it after your app.
- Make sure the new project is selected in the picker before carrying on, the console quietly picks an old project for you.
2. Configure the consent screen
- Open Google Auth Platform and click Get started.
- Set the App name and User support email.
- Set Audience to External and add your contact email.
- Accept the policy and click Create.
The app starts in Testing status, so only emails you add under Audience then Test users can sign in. Add yourself now. The default scopes (openid, email, profile) are all you need, so there is nothing to submit for verification. Leave Branding for now, you will come back to it in step 9.
Docs: Configure the OAuth consent screen
3. Create the OAuth client
- Go to Clients then Create client.
- Set Application type to Web application.
- Under Authorized redirect URIs add the local callback, and the production one if you know it:
1 | http://localhost:3000/api/auth/google/callback |
- Click Create and copy the Client ID and Client secret straight into your env file. Don’t paste the secret into chat or commit it.
1 | # .env.local |
The redirect URI is matched exactly, including scheme, host, port and path. Changes can take a few minutes to apply.
Docs: Setting up OAuth 2.0
4. Redirect to Google
This route builds the authorization URL. It makes a random state, a nonce, and a PKCE verifier, then keeps all three in a short-lived httpOnly cookie so the callback can check them.
1 | // src/app/api/auth/google/route.ts |
The cookie is scoped to /api/auth/google so it is sent to the callback and nowhere else. sameSite: "lax" matters here, the callback is a top-level redirect back from Google and a strict cookie would not be sent.
Docs: OpenID Connect | Google Identity
5. Handle the callback
The callback checks state, swaps the code for tokens, then verifies the ID token signature against Google’s public keys. Verifying the token is the step people skip, don’t.
1 | // src/app/api/auth/google/callback/route.ts |
Treat anything that fails as a generic error and send the user back to the start. The real code should wrap the token exchange and jwtVerify in try/catch and redirect on failure, I left it out so the flow is easy to read.
Docs: jose jwtVerify • Google OAuth 2.0 for web server apps
6. Find or create the user, then start your session
Key your user on sub, the stable Google account id, and never on the email, which can change. Store sub on your user record with a unique, sparse index.
1 | let user = await User.findOne({ googleSub: payload.sub }); |
If you already have users, you need a way to attach a Google account to them or everyone gets a second empty account on their first sign in. The safe option is matching on email, but only when payload.email_verified === true, and only when that user has no googleSub yet. Matching on an unverified email lets someone take over an account.
7. Add the button
A plain anchor is enough. It is a full page redirect to Google, so there is no need for a client side Link.
1 | <a href="/api/auth/google">Sign in with Google</a> |
8. Test it live
- Add
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETto your host. On Vercel that is Settings then Environment Variables, and they only apply to new deployments, so redeploy. - Add the production
https://<your-host>/api/auth/google/callbackto the client’s redirect URIs. - While the app is in Testing, every person who signs in must be on the test user list, see step 9 to open it up.
If something goes wrong
redirect_uri_mismatch: the URI sent in step 4 isn’t in the client’s list. Check scheme, host, port and the trailing path, and give Google a few minutes after saving.
access_denied or “app not verified”: the account isn’t a test user and the app is still in Testing.
Sent back with an error and no detail: state or nonce didn’t match, usually because the cookie was dropped. Check the cookie path, that you are using the same host for the start and the callback (localhost and 127.0.0.1 are different), and that secure isn’t set on plain http.
Signed in as a brand new user: the sub wasn’t known and nothing matched, see the end of step 6.
Docs: Google OAuth 2.0 error reference
9. Go live
Until you publish, only the test users from step 2 can sign in. To open it up to anyone with a Google account:
- Open your project in Google Cloud Console, then Google Auth Platform then Audience.
- Under Publishing status, click Publish app, then Confirm on the “Push to production?” dialog.
My Publish app button was greyed out with the message “To publish your app, you must complete your configuration on the Branding page”. Google doesn’t mark them as required, but the fix was filling in the three empty links under Branding then App domain:
| Field | Value |
|---|---|
| Application home page | https://<your-domain> |
| Application privacy policy link | https://<your-domain>/privacy |
| Application Terms of Service link | https://<your-domain>/terms |
The domain has to be listed under Authorised domains on the same page. That means your app needs real /privacy and /terms pages first. Mine are two static pages (no login needed) that say what is stored, that nothing is sold, and how to ask for deletion. Link them from your home page too, Google’s verification expects the privacy policy to be reachable from there. Put a contact address on the privacy page, people need a way to ask for their data to be deleted.
Check what your domain redirects to before you type the URLs. If <your-domain> sends you to www.<your-domain>, use the www address in the form, and add the www callback to the OAuth client’s redirect URIs from step 3. The callback is built from the host the user arrived on, so it must match the host you actually serve.
Click Save, go back to Audience, and Publish app is now clickable.
Docs: Publishing status
Limitations
- No logo, or you need verification. The Branding page warns that after you upload a logo you must submit the app for verification unless it stays in Testing. The publish dialog says the same: verification is needed if the app has a logo, requests sensitive or restricted scopes, or has more than 10 domains. I published without a logo, so the consent screen shows the app name and no picture. Add a logo later if you are happy to go through the review. Keep the source image (Google wants a square PNG, JPG or BMP under 1 MB, ideally 120 by 120) in your repo so you have it when you do.
- The 100 user cap only bites with sensitive scopes. The Audience page says the cap applies to apps requesting unapproved sensitive or restricted scopes.
openid,emailandprofilearen’t, so I am not capped, and people don’t see the “unverified app” warning. - Open sign up. Once you are in production, any Google account that isn’t linked to an existing user will get a new one from step 6. If you only want invited people, check the email against an allowlist in the callback before creating the user, or use Back to testing and keep the test user list as your gate.
- Changes aren’t instant. Redirect URI and consent screen edits can take a few minutes, sometimes longer, to apply.
Docs: OAuth app verification