preloader
post-thumb

Last Update: June 5, 2026


BYauthor-thumberic

|Loading...

Keywords

"Sign in with Google" sounds easy. It's on every app. How hard can it be?

Very. Especially with Strapi 4 behind an nginx reverse proxy. I spent a full day fighting through a chain of errors, each one hiding the next. This post documents all of them so you don't have to.

The Setup

  • Frontend: Next.js, hosted at https://store.example.com
  • Backend: Strapi 4.25.x, hosted at https://store.example.com/api (proxied by nginx)
  • Strapi admin: https://admin.example.com
  • Reverse proxy: nginx, doing SSL termination and forwarding to the Strapi container

Strapi has Google OAuth built into @strapi/plugin-users-permissions. In theory, you enable the provider in the admin panel, drop in your Google credentials, and it works. In practice, not so much.

The Flow (What's Supposed to Happen)

Worth understanding before you debug it.

  1. User clicks "Sign in with Google" on your frontend
  2. Frontend redirects to https://store.example.com/api/connect/google?callback=<your_frontend_callback_url>
  3. Strapi redirects to Google's OAuth consent screen
  4. Google redirects back to https://store.example.com/api/connect/google/callback
  5. Strapi processes the token, then redirects to your ?callback= URL with an access_token param appended
  6. Your frontend page at /auth/google/callback/ receives that token and exchanges it with /api/auth/google/callback for a JWT
  7. Done — user is logged in

Simple enough on paper. Here's what actually happens.


Error #1: ValidationError: Invalid callback URL provided

What you see:

ValidationError: Invalid callback URL provided
details: { callback: https://store.example.com/auth/google/callback/ }

Strapi validates the ?callback= parameter against the "Redirect URL to front-end app" value you set in the admin panel under Settings → Providers → Google. It's not a prefix match. It's not a domain match. It checks both the origin AND the pathname exactly.

The catch: the admin panel shows you a suggested value like https://admin.example.com/auth/google/callback — that's the URL being accessed through the admin subdomain. But your actual frontend callback lives at store.example.com, not admin.example.com.

Fix: In Strapi admin → Providers → Google → Redirect URL to front-end app, set it to your actual frontend callback URL:

https://store.example.com/auth/google/callback/

Note the trailing slash. If your Next.js app uses trailingSlash: true, the URL in Strapi must match exactly — trailing slash included.


Error #2: Cannot send secure cookie over unencrypted connection

What you see:

Error: Cannot send secure cookie over unencrypted connection

This one appears in the Strapi logs after you've fixed the callback URL. koa-session (which Strapi uses internally) refuses to set a secure cookie because, from Strapi's perspective, the connection is plain HTTP. nginx is doing SSL termination, so by the time the request reaches Strapi, it's unencrypted.

There are two parts to the fix.

Fix part 1 — Tell Strapi to trust the proxy. In src/index.js:

js
module.exports = {
  register() {},
  bootstrap({ strapi }) {
    // Trust X-Forwarded-Proto from nginx so koa-session allows secure cookies
    strapi.server.app.proxy = true;
  },
};

Fix part 2 — Tell nginx to send the header. In your nginx config, inside the /api location block:

nginx
location /api {
    proxy_pass http://backend:1337/api;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_cache_bypass $http_upgrade;
}

Both are required. The header alone does nothing without proxy = true. And proxy = true without the header means Strapi still sees HTTP.


Error #3: Error 400: redirect_uri_mismatch

What you see: Google's error page with "Access blocked: This app's request is invalid."

This one's subtle. There are actually two different redirect URIs involved and it's easy to confuse them.

There's the Strapi admin display — when you look at the Google provider settings in admin.example.com, Strapi helpfully shows you: "The redirect URL to add in your Google application: https://admin.example.com/api/connect/google/callback". That's generated from the URL you used to access the admin. It might not be right.

The actual redirect URI that Strapi sends to Google is built from the PUBLIC_URL environment variable in your Strapi config. If PUBLIC_URL=https://store.example.com, the real URI is:

https://store.example.com/api/connect/google/callback

Fix: In Google Cloud Console → Credentials → OAuth 2.0 Client → Authorized redirect URIs, make sure you have the URI built from PUBLIC_URL — not just whatever the Strapi admin suggested:

https://store.example.com/api/connect/google/callback

You can keep the admin-subdomain one too if you want, but the PUBLIC_URL-based one must be there.


Error #4: Email is already taken

What you see:

Email is already taken.
Back to login

This happens when a user already has a local account (signed up with email/password) and tries to log in via Google using the same email address. Strapi's default behaviour is to reject this — it won't link providers.

The decision here is yours: do you want to block it, or do you want to just sign them in? We went with "just sign them in." It's the sensible UX.

Fix: Override the providers service via a Strapi extension. Create src/extensions/users-permissions/strapi-server.js:

js
'use strict';
const _ = require('lodash');

module.exports = (plugin) => {
  const originalProviders = plugin.services.providers;

  plugin.services.providers = ({ strapi }) => {
    const service = originalProviders({ strapi });
    const originalConnect = service.connect;

    service.connect = async (provider, query) => {
      try {
        return await originalConnect(provider, query);
      } catch (err) {
        if (err.message !== 'Email is already taken.') throw err;

        // Re-fetch the Google profile to get the email
        const accessToken = query.access_token || query.code || query.oauth_token;
        const providers = await strapi
          .store({ type: 'plugin', name: 'users-permissions', key: 'grant' })
          .get();
        const profile = await strapi
          .plugin('users-permissions')
          .service('providers-registry')
          .run({ provider, query, accessToken, providers });

        const email = _.toLower(profile.email);
        const existingUser = await strapi
          .query('plugin::users-permissions.user')
          .findOne({ where: { email } });

        if (!existingUser) throw err;
        return existingUser;
      }
    };

    return service;
  };

  return plugin;
};

Note: don't try to call service.getProfile directly — it's a private closure inside the providers service, not exported. You need to go through providers-registry.run(...) to re-fetch the profile.


Error #5: Forbidden (403 on /api/users/me)

What you see:

The OAuth exchange succeeds. You get a JWT. But when your frontend tries to fetch the user profile — GET /api/users/me with that JWT — it gets a 403. The user gets bounced to "Back to login."

The me endpoint permission in Strapi admin is almost certainly fine. The real culprit is the user's role.

In Strapi, users have a role — either Authenticated or Public. A user with the Public role can't access authenticated endpoints, even with a valid JWT. Strapi's fetchAuthenticatedUser does a role lookup, and permissions are evaluated against that role.

How does a user end up with the Public role? It can be a one-off DB migration artifact, a bug in an older Strapi version, or manual data entry. Check like this:

sql
SELECT u.id, u.email, r.name as role
FROM up_users u
JOIN up_users_role_links l ON u.id = l.user_id
JOIN up_roles r ON l.role_id = r.id
WHERE u.email = '[email protected]';

If you see Public instead of Authenticated, fix it:

sql
UPDATE up_users_role_links SET role_id = 1 WHERE user_id = <affected_user_id>;

(Where role_id = 1 is the Authenticated role — verify against your up_roles table.)

New users created via Google OAuth will get the correct default role as long as Strapi's Advanced settings → Default role is set to authenticated. This was a one-off issue, not a systemic one.


Bonus: The Profile Completion Page

When a brand-new user signs in with Google, they won't have a company name, phone number, or any of the profile fields that a normal sign-up would collect. You probably still want that info.

The approach we used: after a successful Google login, check if the user's profile looks empty. If it is, redirect to a /account/complete-profile page with fields for first name, last name, phone, and company — plus a Skip button if they don't want to fill it in now.

In the Google callback page (/auth/google/callback):

js
const profileIncomplete = !fullUser.customer?.first_name && !fullUser.customer?.last_name;
router.push(profileIncomplete ? "/account/complete-profile" : "/");

The complete-profile page itself is a standard form that calls your user update API. Nothing exotic about it. Just make sure to redirect to /login if there's no JWT in state — someone might land there directly.


The Full Error Chain Summary

None of these above errors tell you what the actual problem is. They all point somewhere else. That's what makes this genuinely frustrating to debug — you fix one thing, restart, and get a completely different error.

But once it's working, it works well. Strapi's built-in OAuth is solid. It just has some rough edges when you're running behind a proxy with existing users.

Comments (0)

Leave a Comment
Your email won't be published. We'll only use it to notify you of replies to your comment.
Loading comments...
Previous Article
post-thumb

Oct 03, 2021

Setting up Ingress for a Web Service in a Kubernetes Cluster with NGINX Ingress Controller

A simple tutorial that helps configure ingress for a web service inside a kubernetes cluster using NGINX Ingress Controller

Next Article
post-thumb

May 29, 2026

Why Does Claude Opus 4.8 Say It Is QWen

Raw Anthropic API tests showed model metadata matching Claude model IDs, while some Chinese self-identification prompts produced Qwen or DeepSeek answers.

agico

We transform visions into reality. We specializes in crafting digital experiences that captivate, engage, and innovate. With a fusion of creativity and expertise, we bring your ideas to life, one pixel at a time. Let's build the future together.

Copyright ©  2026  TYO Lab