✍ Originally published in Japanese on Qiita, this article was translated into English using ChatGPT and then reviewed & edited by the author.
TL;DR
- When you try to use Google OAuth + PKCE from Nuxt 3 CSR, you’ll hit the
client_secret
wall. - Exposing
/auth/exchange
in a tiny BFF (Express / Spring, etc.) solves it. - Full sample on GitHub → https://github.com/RuumaLilja/nuxt3-google-oauth-pkce-sample
Background
Google’s PKCE flow requires client_secret
for Web App registrations.
Since a CSR-only app can’t keep secrets, the realistic solution is to proxy the token exchange through a BFF.
This article walks through a Nuxt 3 CSR + Express micro-service architecture that unblocks the flow.
Table of Contents
- Why the client-secret trap exists
- Four architecture options for pure-front setups
- Step-by-step implementation
- Google Cloud configuration
- Express micro-service (code → token exchange)
- Nuxt 3 CSR client
- Appendix A: Deploy & serverless tips
- Conclusions & next steps
1. Why Google PKCE still wants a client secret
- RFC 7636 (PKCE) targets public clients → secrets should be unnecessary.
- Google’s policy: Web-App registration always demands client authentication.
- A CSR app therefore cannot call the token endpoint directly; a BFF must shield the secret.
2. Four Architecture Options for Pure CSR
Pattern | Outline | Pros | Cons |
---|---|---|---|
A. CSR + Micro-Service (this post) | Express/Lambda handles token exchange | Works with Google, perfect for static hosting | Need to run/maintain a micro-service |
B. SSR + BFF | Keep secret inside Nuxt SSR | Same origin, no CORS hassle | SSR hosting & ops cost |
C. Public-Client Provider | Auth0, Azure AD, etc. | Front-end only | Google not supported |
D. Implicit Flow | Legacy grant | No server | Weaker security |
We implement A: CSR with a tiny Express (Spring works too).
2.1 Other approaches
-
Spring Boot +
spring-boot-starter-oauth2-client
Spring Security can perform the exchange for Java back-ends, but introduces provider-specific dependencies—skipped here.
3. Implementation Steps
3.1 Configure Google Cloud
- OAuth consent screen – set app name & domain.
-
Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Authorized JS origins:
http://localhost:3000
- Redirect URI:
http://localhost:3000/auth/callback
- Copy
client_id
andclient_secret
.
3.2 Express BFF (server/
)
Dependencies: express cors dotenv node-fetch qs
router.post('/', async (req, res) => {
const { code, code_verifier } = req.body;
const conf = await fetch(process.env.OIDC_WELL_KNOWN).then(r => r.json());
const token = await fetch(conf.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: qs.stringify({
grant_type: 'authorization_code',
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uri: process.env.OIDC_REDIRECT_URI,
code,
code_verifier,
}),
}).then(r => r.json());
res.json(token);
});
3.3 Nuxt 3 CSR (client/
)
- Dependencies:
@pinia/nuxt
,oidc-client-ts
- Override
token_endpoint
to your BFF:apiBase + '/auth/exchange'
. - Handle the callback:
export const handleCallback = async () => {
const user = await mgr.signinCallback();
store.setTokens({ access_token: user.access_token });
store.setUser(user.profile);
await navigateTo('/');
};
3.4 Run locally
# server
npm run dev
# client
npm run dev
Appendix A. Deploy & Serverless Tips
Layer | Example platform | Key points |
---|---|---|
Front-end | Netlify / Vercel / Cloudflare Pages | Deploy nuxt generate output as-is |
BFF | Vercel Functions / Cloudflare Workers / AWS Lambda | Store .env secrets in platform settings |
4. Conclusions & Next Steps
- Refresh-token handling: httpOnly cookies + rotation
- Security hardening:
express-rate-limit
, Helmet, strict CORS - Other IdPs: Microsoft identity platform lets you stay front-end-only
- SSR/BFF migration: integrate with Nuxt Nitro if you ever need a unified origin
5. Source Code
All code shown here:
https://github.com/RuumaLilja/nuxt3-google-oauth-pkce-sample
Hope this saves you from falling into Google OAuth’s “client_secret” pit!