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()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 (may be opaque, not always JWT)
access_token - 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 Type | Format | Purpose | Decodable? | Use Case |
|---|---|---|---|---|
| Access Token | Opaque or JWT | Access protected APIs | ❌ Maybe | Call external APIs |
| ID Token | Always JWT | User identity claims | ✅ Always | Authenticate user in your app |
| Refresh Token | Opaque | Get new tokens | ❌ No | Refresh 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.tsimport 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.tsimport "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.tsBefore (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()// ❌ 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()
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
AuthorizationAuthorization: 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".
Related Issues
This fix resolves several related problems:
- "Token is not a valid JWT" - Access tokens may be opaque
- "Missing user claims" - Access tokens don't have email, name, etc.
- "Cannot decode token" - Opaque tokens cannot be decoded locally
- "Authentication works in login but fails in API" - Wrong token type
- "Logout doesn't work properly" - ID token needed for OIDC logout
When to Use Each Token Type
| Scenario | Token to Use | Why |
|---|---|---|
| Authenticate user to your backend | ID Token | Always JWT, has user claims |
| Call ZewstID Admin API | Access Token | Authorized for ZewstID resources |
| Call user's Google Calendar | Access Token | Authorized for Google resources |
| Store user session | ID Token | Has complete user identity |
| Refresh expired tokens | Refresh Token | Exchange for new access/ID tokens |
Prevention
For New Integrations
When integrating a new app with ZewstID:
- ✅ Use SDK v0.4.6+ or
createZewstIDAuth() - ✅ Store both tokens in NextAuth callbacks
- ✅ Use ID token for backend authentication
- ✅ Use access token only for calling external APIs
- ✅ 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 instead of
session.idTokensession.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:
- ZEYE-APP-FIX-INSTRUCTIONS.md (Different issue - redirect URLs)
- DEVELOPER-INTEGRATION-GUIDE-V2.md
- SDK-V0.4.5-RELEASE-NOTES.md
Was this page helpful?
Let us know how we can improve our documentation