OAuth2 PKCE in a Nuxt route middleware
14 March 2025
The users already had accounts with a corporate identity provider. Building a separate login system would mean another set of credentials for people who have too many already. So the registration platform needed to authenticate via OAuth2 with PKCE — the flow where the client generates a code challenge, the identity provider verifies it on token exchange, and no client secret ever touches the browser.
I implemented this as a Nuxt route middleware. Every page load checks for a valid token. If there isn’t one, the middleware generates a PKCE challenge, stores state in cookies, and redirects to the identity provider. When the user comes back with an authorisation code, a server route validates it.
The middleware
The auth middleware runs on every navigation. It checks for an existing token, validates it server-side, and if validation fails, starts the PKCE flow:
export default defineNuxtRouteMiddleware(async (to, from) => {
const config = useRuntimeConfig()
const { checkToken, checkRegistration, loginWithCode } = useAuthStore()
const token = useCookie('token')
let hasToken = !!token.value
if (token.value) {
const check = await checkToken(token.value)
hasToken = check.hasAccessToken
if (hasToken) {
const registration = await checkRegistration(eventAlias)
if (registration?.code) {
await loginWithCode(registration.code)
}
}
}
if (!hasToken) {
const { data } = await useFetch('/api/code-challenge')
const { state, code_challenge, code_verifier } = data.value
useCookie('state').value = state
useCookie('code_verifier').value = code_verifier
const authUrl = `${config.public.authorizationUrl}`
+ `?client_id=${config.public.clientId}`
+ `&response_type=code`
+ `&redirect_uri=${config.public.redirectUri}`
+ `&scope=${config.public.scope}`
+ `&state=${state}`
+ `&code_challenge=${code_challenge}`
+ `&code_challenge_method=S256`
return navigateTo(authUrl, { external: true })
}
})
The state and code_verifier go into cookies so they survive the redirect. When the identity provider sends the user back with an authorisation code, the callback handler can retrieve the verifier and complete the exchange.
Server-side token validation
The code challenge is generated by a server route that calls the corporate middleware API:
// server/api/code-challenge.js
export default defineEventHandler(async (event) => {
const response = await $fetch(`${middlewareUrl}/oauth/code-challenge`, {
headers: middlewareHeaders,
})
return response
})
Token validation also happens server-side, passing the access token in a custom header:
// server/api/token/check.js
export default defineEventHandler(async (event) => {
const { token } = await readBody(event)
let hasAccessToken = false
let user = null
await $fetch(`${middlewareUrl}/oauth/token`, {
headers: {
...middlewareHeaders,
'X-Access-Token': token,
},
onResponse: ({ response }) => {
hasAccessToken = response.status === 200
user = response._data
},
}).catch(() => {
hasAccessToken = false
})
return { hasAccessToken, user }
})
This keeps the token exchange server-side. The browser never sees the client secret or the raw token validation response — it just gets a boolean and the user profile.
Internal vs external user detection
The identity provider returns a userType field that distinguishes internal staff from external partner users. Some event waves were internal-only, so the auth store exposes this as a computed:
const isInternal = computed(() => {
const types = userSession.value?.userType?.split(', ') || []
return types.length > 0 && !types.includes('External Partner')
})
The event store then uses this to filter which waves are visible. Internal-only waves just disappear from the UI for external users — no error message, no “you don’t have access” banner. They never see what they can’t book.
Delegated registration
One wrinkle: some users are representatives who manage registrations for multiple organisations. When the registration check returns is_delegate: true, the auth store stores the organisation list and the app redirects to a selection page before proceeding. The same PKCE flow handles auth — the delegation routing happens after authentication, not during.
The middleware approach means page components don’t know about OAuth at all. They receive an authenticated user from the store and render accordingly. The entire PKCE flow — challenge generation, redirect, token exchange, validation — lives in the middleware and two server routes. If the identity provider changes, those are the only files that need updating.