Skip to main content

Troubleshooting: Token Type Mismatch (Access Token vs ID Token)

Issue: Backend cannot decode OAuth tokens sent from frontend Symptom: JWT decode errors, authentication failures despite successful OAuth login Root Cause: Frontend sending access token instead of ID token Severity: High - Blocks authentication for integrated apps Last Updated: January 25, 2026 SDK Version: v0.7.3 -

getZewstIDCallbacks()
stores ID token correctly


Problem Description

When integrating applications with ZewstID using NextAuth.js (or similar OAuth libraries), a common issue occurs when the frontend sends the wrong token type to the backend:

  • Frontend sends: OAuth
    access_token
    (may be opaque, not always JWT)
  • Backend expects: ID token (always JWT with user claims)
  • Result: Backend cannot decode the token → authentication fails

Example Error

// Backend error when trying to decode access token TypeError: Cannot read property 'sub' of null at decodeJWT (auth.ts:99)

Understanding OAuth/OIDC Tokens

In an OAuth/OIDC flow, you receive three types of tokens:

Token TypeFormatPurposeDecodable?Use Case
Access TokenOpaque or JWTAccess protected APIs❌ MaybeCall external APIs
ID TokenAlways JWTUser identity claims✅ AlwaysAuthenticate user in your app
Refresh TokenOpaqueGet new tokens❌ NoRefresh expired tokens

Key Point

For user authentication in your backend, always use the ID token.

The access token is designed for calling third-party APIs (like Google Calendar, GitHub repos, etc.), not for authenticating the user to your own backend.


How to Identify This Issue

1. Check What Token is Being Sent

Browser DevTools → Network tab:

# Request to your backend API GET /api/protected-route Authorization: Bearer eyJhbGc... # ← What token is this?

Decode the token at jwt.io:

// If it's an access token (problematic): { "aud": "account", "typ": "Bearer", "azp": "your-client-id" // Missing: email, name, email_verified, etc. } // If it's an ID token (correct): { "aud": "your-client-id", "typ": "ID", "sub": "user-uuid", "email": "user@example.com", "email_verified": true, "name": "John Doe" // ← Has all user claims }

2. Check Backend Logs

# Look for JWT decode errors Error: Token is not a valid JWT Error: Cannot decode opaque token Error: Missing required claims (sub, email)

Solution: Send ID Token Instead

Step 1: Update NextAuth Configuration

File:

app/api/auth/[...nextauth]/route.ts
(or wherever you configure NextAuth)

import NextAuth from "next-auth"; export const authOptions = { providers: [ { id: "zewstid", name: "ZewstID", type: "oauth", // ... other config } ], callbacks: { async jwt({ token, account, user }) { // After initial sign-in, save all tokens if (account) { token.accessToken = account.access_token; token.idToken = account.id_token; // ← ADD THIS token.refreshToken = account.refresh_token; } return token; }, async session({ session, token }) { // Make tokens available to the client session.accessToken = token.accessToken as string; session.idToken = token.idToken as string; // ← ADD THIS return session; }, }, }; const handler = NextAuth(authOptions); export { handler as GET, handler as POST };

Step 2: Update TypeScript Types

File:

types/next-auth.d.ts
(or create it)

import "next-auth"; declare module "next-auth" { interface Session { accessToken?: string; idToken?: string; // ← ADD THIS refreshToken?: string; } } declare module "next-auth/jwt" { interface JWT { accessToken?: string; idToken?: string; // ← ADD THIS refreshToken?: string; } }

Step 3: Update API Client to Send ID Token

File:

lib/api/client.ts
(or wherever you make API calls)

Before (incorrect):

const response = await fetch('/api/protected', { headers: { 'Content-Type': 'application/json', Authorization: session.accessToken ? `Bearer ${session.accessToken}` : '', }, });

After (correct):

const response = await fetch('/api/protected', { headers: { 'Content-Type': 'application/json', Authorization: session.idToken ? `Bearer ${session.idToken}` : '', // ← CHANGED }, });

Step 4: Backend Stays the Same (Should Work Now)

Your backend JWT decoding code should work without changes:

// This will now work because ID tokens are always JWTs const decoded = jwt.decode(token); console.log(decoded.sub); // ✅ User ID console.log(decoded.email); // ✅ User email

SDK Fix (v0.4.6)

What Was Wrong

The ZewstID SDK's

getZewstIDCallbacks()
function in versions ≤0.4.4 did NOT store the ID token:

// ❌ Old implementation (v0.4.4 and earlier) export function getZewstIDCallbacks(clientId: string) { return { async jwt({ token, account }: any) { if (account && account.id_token) { // ... extract user data from ID token // Store ONLY the access token token.accessToken = account.access_token; // ❌ ID token NOT stored! } return token; }, async session({ session, token }: any) { session.accessToken = token.accessToken; // ❌ ID token NOT available! return session; }, }; }

What's Fixed

Version 0.4.6 now stores the ID token:

// ✅ New implementation (v0.4.6+) export function getZewstIDCallbacks(clientId: string) { return { async jwt({ token, account }: any) { if (account && account.id_token) { // ... extract user data from ID token // Store access token token.accessToken = account.access_token; // ✅ Store ID token for proper OIDC logout and backend authentication token.idToken = account.id_token; } return token; }, async session({ session, token }: any) { session.accessToken = token.accessToken; // ✅ Make ID token available session.idToken = token.idToken; return session; }, }; }

Migration Guide

If you're using the ZewstID SDK:

Option 1: Upgrade to v0.4.6+ (Recommended)

npm install @zewstid/nextjs@0.4.6

Your existing code will automatically get ID token support:

import { ZewstIDProvider, getZewstIDCallbacks } from '@zewstid/nextjs'; const authOptions = { providers: [ ZewstIDProvider({ clientId: process.env.ZEWSTID_CLIENT_ID!, clientSecret: process.env.ZEWSTID_CLIENT_SECRET!, }), ], callbacks: getZewstIDCallbacks(process.env.ZEWSTID_CLIENT_ID!), }; // ✅ session.idToken is now available!

Option 2: Use

createZewstIDAuth()
(Already has ID token)

import { createZewstIDAuth } from '@zewstid/nextjs'; export const authOptions = createZewstIDAuth({ clientId: process.env.ZEWSTID_CLIENT_ID!, clientSecret: process.env.ZEWSTID_CLIENT_SECRET!, issuer: 'https://auth.zewstid.com/realms/zewstid', }); // ✅ This always had ID token support

Alternative Solutions

Option 2: Use Access Token Introspection

If you must use access tokens (e.g., for calling ZewstID APIs), use the introspection endpoint:

async function validateAccessToken(token: string) { const response = await fetch( 'https://auth.zewstid.com/realms/zewstid/protocol/openid-connect/token/introspect', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token, client_id: process.env.ZEWSTID_CLIENT_ID!, client_secret: process.env.ZEWSTID_CLIENT_SECRET!, }), } ); const data = await response.json(); if (!data.active) { throw new Error('Token is not active'); } return { sub: data.sub, email: data.email, email_verified: data.email_verified, // ... other claims }; }

Downsides:

  • Extra network request for every API call
  • Slower than local JWT verification
  • Requires client secret (security concern)

Option 3: Use UserInfo Endpoint

Alternative to introspection:

async function getUserFromAccessToken(accessToken: string) { const response = await fetch( 'https://auth.zewstid.com/realms/zewstid/protocol/openid-connect/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, } ); if (!response.ok) { throw new Error('Failed to get user info'); } return response.json(); // Returns user claims }

Downsides:

  • Extra network request
  • Slower performance

Testing the Fix

1. Clear Existing Session

# In browser DevTools Console localStorage.clear(); sessionStorage.clear();

Then manually clear cookies for your domain.

2. Login Again

Navigate to your app and login through ZewstID OAuth flow.

3. Verify ID Token is Sent

Browser DevTools → Network tab:

Look for API requests to your backend. Check the

Authorization
header:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Copy the token and decode it at jwt.io. You should see:

{ "exp": 1735430400, "iat": 1735426800, "aud": "your-client-id", "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "typ": "ID", // ← Should be "ID" "email": "user@example.com", // ← Should have email "email_verified": true, // ← Should have email_verified "name": "John Doe", // ← Should have name "preferred_username": "john" }

4. Verify Backend Decodes Successfully

Check your backend logs. You should see:

✅ JWT decoded successfully ✅ User ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 ✅ Email: user@example.com

No errors about "invalid JWT" or "cannot decode token".


This fix resolves several related problems:

  1. "Token is not a valid JWT" - Access tokens may be opaque
  2. "Missing user claims" - Access tokens don't have email, name, etc.
  3. "Cannot decode token" - Opaque tokens cannot be decoded locally
  4. "Authentication works in login but fails in API" - Wrong token type
  5. "Logout doesn't work properly" - ID token needed for OIDC logout

When to Use Each Token Type

ScenarioToken to UseWhy
Authenticate user to your backendID TokenAlways JWT, has user claims
Call ZewstID Admin APIAccess TokenAuthorized for ZewstID resources
Call user's Google CalendarAccess TokenAuthorized for Google resources
Store user sessionID TokenHas complete user identity
Refresh expired tokensRefresh TokenExchange for new access/ID tokens

Prevention

For New Integrations

When integrating a new app with ZewstID:

  1. Use SDK v0.4.6+ or
    createZewstIDAuth()
  2. Store both tokens in NextAuth callbacks
  3. Use ID token for backend authentication
  4. Use access token only for calling external APIs
  5. Test token decoding before deploying

Documentation References


Affected Apps

Known Cases:

  • ZEYE App - Fixed December 28, 2025 (manual fix in app code)
  • ZewstID SDK - Fixed in v0.4.6 (December 28, 2025)

Apps Using SDK v0.4.4 or Earlier:

  • ⚠️ Upgrade to v0.4.6+ to get automatic ID token support

Apps Not Using SDK:

  • ⚠️ Any app using NextAuth.js with ZewstID
  • ⚠️ Any app decoding tokens in backend
  • ⚠️ Follow manual fix steps above

Summary

Problem: Frontend sends access token, backend expects ID token Solution:

  • SDK users: Upgrade to v0.4.6+
  • Non-SDK users: Update NextAuth to send
    session.idToken
    instead of
    session.accessToken

Impact: Fixes authentication failures across all API endpoints Effort:

  • SDK users: ~2 minutes (npm update)
  • Non-SDK users: ~15 minutes (3 file changes)

This is a common OAuth/OIDC integration mistake. Always use ID tokens for user authentication in your own backend.


Created: December 28, 2025 Author: Claude AI Assistant Status: Solution documented and tested SDK Fix: v0.4.5 Related Docs:

Was this page helpful?

Let us know how we can improve our documentation