
Last Update: June 5, 2026
BY
eric
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.
- User clicks "Sign in with Google" on your frontend
- Frontend redirects to
https://store.example.com/api/connect/google?callback=<your_frontend_callback_url> - Strapi redirects to Google's OAuth consent screen
- Google redirects back to
https://store.example.com/api/connect/google/callback - Strapi processes the token, then redirects to your
?callback=URL with anaccess_tokenparam appended - Your frontend page at
/auth/google/callback/receives that token and exchanges it with/api/auth/google/callbackfor a JWT - 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:
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:
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:
'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:
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:
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):
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.
Previous Article
Next Article
May 29, 2026





Comments (0)
Leave a Comment