Start HereOrdered learning track

Client-side Token Decoding

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 017

Client-side token decoding in React without confusing JWT claims with authority, including safe use cases, dangerous use cases, stale claims, claim trust boundaries, and production implementation patterns.

10 min read1925 words
PrevNext
Lesson 17130 lesson track01–24 Start Here
#react#authentication#authorization#jwt+4 more

Part 017 — Client-side Token Decoding

Client-side token decoding is useful.

Client-side token decoding is also one of the easiest ways to accidentally build a fake authorization system.

The mistake usually starts innocently:

const token = localStorage.getItem("access_token");
const payload = jwtDecode(token);

if (payload.role === "admin") {
  showAdminButton();
}

Then the app grows.

The decoded role controls menus. The decoded tenant controls active workspace. The decoded scopes control action buttons. The decoded expiry controls refresh. The decoded profile controls audit context. Eventually, half the frontend behaves as if a decoded token is the source of truth.

That is the boundary failure.

A decoded token is not an authorization decision.

It is only a parsed representation of a credential that was issued at some earlier time for some audience under some policy.

The browser may read it. The browser may use it for convenience. The browser must not become the authority that decides whether protected actions are allowed.

This part builds the mental model and implementation discipline for decoding tokens in React without lying to yourself about security.


1. The core distinction

There are three separate operations that engineers often collapse into one:

Decode     -> parse token structure
Validate   -> prove token integrity and acceptability
Authorize  -> decide whether a subject may perform an action on a resource

They are not equivalent.

Decoding answers:

What does this token say?

Validation answers:

Was this token legitimately issued, still acceptable, and intended for this recipient?

Authorization answers:

Given current state, may this subject perform this action on this resource?

React can decode. React generally should not validate access tokens as an authority. React must not enforce authorization for protected resources.


2. What token decoding actually does

A JWT has three base64url-encoded parts:

header.payload.signature

A basic decode operation reads the header and payload.

import { jwtDecode } from "jwt-decode";

type AccessTokenClaims = {
  iss?: string;
  sub?: string;
  aud?: string | string[];
  exp?: number;
  iat?: number;
  nbf?: number;
  scope?: string;
  tenant_id?: string;
  org_id?: string;
};

export function decodeAccessTokenUnsafe(token: string): AccessTokenClaims | null {
  try {
    return jwtDecode<AccessTokenClaims>(token);
  } catch {
    return null;
  }
}

The function name intentionally includes Unsafe.

That does not mean decoding is always bad. It means the output should not be treated as proven authority merely because parsing succeeded.

A malicious or corrupted token can still decode.

Decode success != valid token
Decode success != active session
Decode success != current permission
Decode success != allowed action

The browser can inspect the text. The server decides whether the credential is acceptable.


3. Safe use cases for client-side decoding

Client-side decoding is acceptable when the result is used as a hint, optimization, or UX convenience.

3.1 Reading expiry for proactive refresh

The frontend may decode exp to schedule proactive refresh.

type RefreshPlan =
  | { kind: "refresh-at"; epochMs: number }
  | { kind: "refresh-now" }
  | { kind: "unknown" };

export function planRefreshFromDecodedExp(
  claims: { exp?: number } | null,
  options: {
    serverTimeOffsetMs: number;
    refreshBeforeMs: number;
  }
): RefreshPlan {
  if (!claims?.exp) return { kind: "unknown" };

  const serverNowMs = Date.now() + options.serverTimeOffsetMs;
  const expiresAtMs = claims.exp * 1000;
  const refreshAtMs = expiresAtMs - options.refreshBeforeMs;

  if (refreshAtMs <= serverNowMs) {
    return { kind: "refresh-now" };
  }

  return { kind: "refresh-at", epochMs: refreshAtMs };
}

But this is only scheduling.

The server still has the final say.

A token may be revoked before exp. A token may be rejected because of audience mismatch. The session may be disabled. The tenant membership may have changed.

So the correct invariant is:

Decoded expiry may trigger refresh early.
Server response remains authoritative.

3.2 Showing non-sensitive display hints

The frontend may decode low-risk display hints such as:

preferred_username
picture
locale
name

Even then, prefer /me or /session for production UIs because token claims can be stale and may contain more information than the UI should depend on.

A decoded display claim should be treated like cached profile data:

Useful for reducing flicker.
Not authoritative for business logic.

3.3 Debug panels in non-production environments

Token decoding is useful in dev tools, diagnostics, and test harnesses.

export function DevTokenInspector({ token }: { token: string | null }) {
  if (process.env.NODE_ENV === "production") return null;
  if (!token) return null;

  const claims = decodeAccessTokenUnsafe(token);

  return (
    <details>
      <summary>Decoded token claims</summary>
      <pre>{JSON.stringify(redactClaims(claims), null, 2)}</pre>
    </details>
  );
}

function redactClaims(claims: unknown) {
  if (!claims || typeof claims !== "object") return claims;

  const copy = { ...(claims as Record<string, unknown>) };
  delete copy.email;
  delete copy.phone_number;
  delete copy.address;
  return copy;
}

Keep diagnostic output behind environment gates. Do not leak tokens or sensitive claims into logs, analytics, monitoring breadcrumbs, screenshots, or bug report payloads.

3.4 UX preflight hints

The frontend may use decoded claims to avoid obviously impossible UI states.

Example:

If token says tenant_id = A,
avoid immediately rendering tenant B layout while /session is loading.

But the UI must still reconcile with server session state.

Decoded claim -> provisional state
/session response -> committed state

4. Dangerous use cases

4.1 Authorization based on decoded roles

This is the classic bug.

function DeleteCaseButton({ caseId }: { caseId: string }) {
  const token = useAccessToken();
  const claims = token ? jwtDecode<{ role?: string }>(token) : null;

  if (claims?.role !== "admin") return null;

  return <button onClick={() => deleteCase(caseId)}>Delete</button>;
}

This code has multiple problems:

  • role may be stale.
  • role may not apply to this tenant.
  • role may not apply to this specific resource.
  • role may represent identity provider role, not application role.
  • Hiding the button does not protect the API.
  • Showing the button does not guarantee the mutation will succeed.

The better model:

function DeleteCaseButton({ caseId }: { caseId: string }) {
  const permission = useCan({ action: "case.delete", resource: { type: "case", id: caseId } });

  if (permission.status === "loading") return null;
  if (!permission.allowed) return null;

  return <button onClick={() => deleteCase(caseId)}>Delete</button>;
}

And the API still checks:

DELETE /cases/:caseId
  -> authenticate request
  -> resolve subject
  -> load case
  -> verify tenant boundary
  -> evaluate policy case.delete
  -> write audit event
  -> delete or deny

Frontend permission is exposure control. Backend policy is enforcement.

4.2 Treating decoded scope as application permission

OAuth scope is often misunderstood.

A token with:

scope = "read:cases write:cases"

may only mean that the token is allowed to request those API categories. It does not necessarily mean the current user can edit this exact case.

Application authorization may depend on:

subject.role
subject.tenant
case.owner
case.status
case.confidentiality
case.assignedTeam
case.region
currentTime
approvalState
regulatoryHold

A scope can be part of authorization input. It is not the whole decision.

4.3 Decoding ID token as application session authority

An ID token tells the client about authentication performed by the identity provider.

It is not automatically an application session.

Wrong model:

ID token exists -> user is logged into my app -> user may access resources

Better model:

ID token or auth code proves identity flow completed.
Application server establishes app session.
Application session controls app access.

In a BFF model, React may never need to see ID tokens or access tokens. It asks:

GET /session

and receives an application-specific session projection:

{
  "authenticated": true,
  "user": {
    "id": "usr_123",
    "displayName": "Ari"
  },
  "activeTenant": {
    "id": "tenant_456",
    "name": "Regulatory Ops"
  },
  "assurance": {
    "level": "mfa",
    "expiresAt": "2026-07-08T09:30:00Z"
  }
}

4.4 Using decoded tenant claim as tenant enforcement

Tenant claims are useful. They are not sufficient.

Token tenant_id = tenant_a
Request URL = /tenants/tenant_b/cases/123

The server must reject tenant mismatch.

The frontend may use tenant_id to select default workspace, but tenant boundary must be enforced server-side on every resource access.

4.5 Logging decoded claims casually

Decoded claims often contain:

  • user identifiers,
  • email,
  • tenant identifiers,
  • group membership,
  • roles,
  • identity provider information,
  • authentication time,
  • sometimes accidental PII.

Do not log decoded tokens as debugging convenience.

// Bad
console.log("claims", jwtDecode(token));

// Less bad, still dev-only
logger.debug("token_exp", { exp: claims.exp, aud: claims.aud });

Even better: log server-side correlation IDs and session event IDs, not raw token content.


5. Trust boundary table

Token claimFrontend may use forFrontend must not use for
expRefresh scheduling, session countdown hintDeciding server acceptance
iatDebugging age, stale-token warningTrusting freshness without server
nbfAvoid using obviously not-yet-valid tokenFinal validation
issDiagnostics, expected provider hintAccepting token issuer as valid
audDiagnostics, API mismatch warningProving intended audience
subProvisional identity keyAccess control by itself
emailTemporary display hintStable identity key or authorization
scopeCapability hintResource-level permission decision
roleUI hint only if server contract allowsEnforcement or business-critical action
tenant_idDefault active tenant hintTenant isolation enforcement
groupsAvoid if possible; may be too large/staleFine-grained authorization

The strongest frontend pattern is not:

Decode more claims.

It is:

Ask the backend for the exact projection the frontend needs.

6. Prefer session projection over raw token claims

A React app usually needs an application-specific view of the session:

type SessionProjection =
  | {
      status: "anonymous";
    }
  | {
      status: "authenticated";
      user: {
        id: string;
        displayName: string;
        avatarUrl?: string;
      };
      activeTenant: {
        id: string;
        name: string;
      };
      assurance: {
        level: "password" | "mfa" | "phishing-resistant";
        stepUpRequiredFor: string[];
      };
      expiresAt: string;
    };

This projection is better than exposing the token payload directly because it can be:

  • minimized,
  • normalized,
  • redacted,
  • tenant-aware,
  • application-specific,
  • versioned,
  • easier to test,
  • safer to evolve.

Example endpoint:

GET /session

Response:

{
  "status": "authenticated",
  "user": {
    "id": "usr_123",
    "displayName": "Maya Putri"
  },
  "activeTenant": {
    "id": "tnt_001",
    "name": "Financial Conduct"
  },
  "assurance": {
    "level": "mfa",
    "stepUpRequiredFor": ["payment.approve", "case.delete"]
  },
  "expiresAt": "2026-07-08T09:30:00Z"
}

React consumes this as auth state.

The token remains a transport credential.


7. A production-friendly token decoding boundary

If your architecture still exposes an access token to React, isolate token decoding behind a small boundary.

Do not let random components import jwtDecode.

Bad:
  Any component can decode and branch on claims.

Better:
  Only auth/session module can decode.
  It exposes safe derived values.

Implementation:

// auth/tokenClaims.ts
import { jwtDecode } from "jwt-decode";

export type RawJwtClaims = {
  iss?: string;
  sub?: string;
  aud?: string | string[];
  exp?: number;
  iat?: number;
  nbf?: number;
  scope?: string;
  tenant_id?: string;
};

export type TokenDiagnostics = {
  issuer?: string;
  audience?: string | string[];
  expiresAt?: string;
  issuedAt?: string;
  tenantHint?: string;
};

export function decodeClaimsForAuthModuleOnly(token: string): RawJwtClaims | null {
  try {
    return jwtDecode<RawJwtClaims>(token);
  } catch {
    return null;
  }
}

export function toTokenDiagnostics(claims: RawJwtClaims | null): TokenDiagnostics {
  return {
    issuer: claims?.iss,
    audience: claims?.aud,
    expiresAt: claims?.exp ? new Date(claims.exp * 1000).toISOString() : undefined,
    issuedAt: claims?.iat ? new Date(claims.iat * 1000).toISOString() : undefined,
    tenantHint: claims?.tenant_id,
  };
}

Add linting discipline:

Rule:
  jwtDecode may only be imported inside src/auth/**

Example ESLint restriction:

// eslint.config.js
export default [
  {
    rules: {
      "no-restricted-imports": [
        "error",
        {
          paths: [
            {
              name: "jwt-decode",
              message: "Decode tokens only inside src/auth. Use session and permission APIs in components.",
            },
          ],
        },
      ],
    },
  },
];

Then override for src/auth/** if needed.


8. Typed claim parsing is still not validation

TypeScript helps prevent shape mistakes. It does not prove trust.

import { z } from "zod";

const AccessTokenClaimSchema = z.object({
  iss: z.string().optional(),
  sub: z.string().optional(),
  aud: z.union([z.string(), z.array(z.string())]).optional(),
  exp: z.number().int().positive().optional(),
  iat: z.number().int().positive().optional(),
  nbf: z.number().int().positive().optional(),
  scope: z.string().optional(),
  tenant_id: z.string().optional(),
});

export function parseDecodedClaims(value: unknown) {
  const result = AccessTokenClaimSchema.safeParse(value);
  return result.success ? result.data : null;
}

This proves only:

The decoded object has the expected shape.

It does not prove:

The token signature is valid.
The issuer is trusted.
The audience matches.
The token is unexpired.
The session is active.
The user still has permission.

Schema validation is input hygiene, not security authority.


9. Stale claims are the normal case

A JWT claim is a snapshot.

Even when legitimately issued, it can become stale.

Example timeline:

This is not an edge case. It is normal.

Roles change. Tenants change. Cases change state. Assignments move. Approval windows close. Regulatory holds appear. Access can be revoked before token expiry.

Therefore:

Token claims are not fresh enough for fine-grained authorization.

For high-risk actions, use a fresh server-side permission check.


10. Claim freshness by decision type

Decision typeCan use decoded token?Better source
Show display name while bootstrappingYes, as hint/session
Schedule access token refreshYes, as hintServer response still authoritative
Decide if user is authenticatedNot alone/session, successful server-authenticated request
Decide visible menu itemsMaybe as temporary skeleton hint/permissions or route loader data
Decide resource action allowedNoServer policy decision
Decide tenant accessNoServer tenant membership check
Decide admin operationNoServer policy + audit + step-up if needed
Decide field-level accessNoServer-provided form schema/field permissions
Decide impersonation allowedNoServer policy + audit + explicit session state

A useful heuristic:

If a wrong answer creates only a temporary visual glitch, decoded claims may be acceptable.
If a wrong answer exposes sensitive data or enables mutation, decoded claims are not acceptable.

11. The “decoded token as auth context” trap

Many React apps put decoded claims directly into context:

<AuthContext.Provider value={{ user: jwtDecode(token) }}>
  {children}
</AuthContext.Provider>

This is convenient and dangerous.

The context becomes polluted with transport-level data:

AuthContext.user.role
AuthContext.user.groups
AuthContext.user.scope
AuthContext.user.tenant_id
AuthContext.user.email

Components start branching on claims. Authorization logic spreads everywhere. Refactoring becomes expensive.

Better context shape:

type AuthContextValue = {
  session: SessionProjection;
  refreshSession: () => Promise<void>;
  logout: () => Promise<void>;
};

Permission belongs in a separate boundary:

type PermissionContextValue = {
  can: (input: PermissionInput) => PermissionDecision;
  refreshPermissions: () => Promise<void>;
};

The raw token should not be the domain model.


12. Good React auth context design

import { createContext, useContext } from "react";

export type AuthSession =
  | { status: "loading" }
  | { status: "anonymous" }
  | {
      status: "authenticated";
      user: {
        id: string;
        displayName: string;
      };
      activeTenant: {
        id: string;
        name: string;
      };
      expiresAt: string;
    }
  | { status: "expired" }
  | { status: "error"; recoverable: boolean };

type AuthContextValue = {
  session: AuthSession;
  refresh: () => Promise<void>;
  logout: () => Promise<void>;
};

const AuthContext = createContext<AuthContextValue | null>(null);

export function useAuth() {
  const value = useContext(AuthContext);
  if (!value) throw new Error("useAuth must be used inside AuthProvider");
  return value;
}

Notice what is not included:

raw token
raw decoded claims
role string from JWT
permission decision based on JWT

This forces business components to depend on app-level session and permission contracts.


13. Token decoding and route protection

Route protection should not be:

const claims = jwtDecode(token);
return claims.exp > Date.now() / 1000 ? <Outlet /> : <Navigate to="/login" />;

That can create subtle bugs:

  • token looks unexpired but session was revoked,
  • token has valid shape but wrong audience,
  • token exists but backend rejects it,
  • token belongs to a different tenant,
  • decoded value causes route to render sensitive UI before server bootstrap.

Better data-router model:

export async function protectedLoader({ request }: LoaderFunctionArgs) {
  const session = await getSessionFromServer(request);

  if (session.status === "anonymous") {
    throw redirect(`/login?returnTo=${encodeURIComponent(new URL(request.url).pathname)}`);
  }

  if (session.status !== "authenticated") {
    throw new Response("Session unavailable", { status: 503 });
  }

  return { session };
}

In a pure SPA, the equivalent is still server-backed bootstrap:

async function restoreSession(): Promise<AuthSession> {
  const response = await fetch("/session", {
    credentials: "include",
    headers: { Accept: "application/json" },
  });

  if (response.status === 401) return { status: "anonymous" };
  if (!response.ok) return { status: "error", recoverable: true };

  return response.json();
}

Decode only as a supplement, not as the gate.


14. Token decoding and permission UI

A safe permission architecture separates three concepts:

Session identity    -> Who is interacting with the app?
Permission contract -> What actions should UI expose?
Enforcement policy  -> What does the backend allow now?

Frontend permission contract example:

{
  "resource": {
    "type": "case",
    "id": "case_123"
  },
  "actions": {
    "case.view": {
      "allowed": true
    },
    "case.assign": {
      "allowed": false,
      "reason": "CASE_LOCKED"
    },
    "case.close": {
      "allowed": false,
      "reason": "REQUIRES_SUPERVISOR_REVIEW"
    }
  }
}

React usage:

function CaseActions({ caseId }: { caseId: string }) {
  const permissions = useCasePermissions(caseId);

  if (permissions.status !== "ready") return null;

  return (
    <ActionBar>
      {permissions.can("case.assign") && <AssignButton caseId={caseId} />}
      {permissions.can("case.close") && <CloseCaseButton caseId={caseId} />}
    </ActionBar>
  );
}

This is better than reading:

jwt.role
jwt.scope
jwt.groups

because the server can account for current resource state.


15. Do not put authorization into token decoding helpers

Avoid helpers like this:

export function isAdmin(token: string): boolean {
  const claims = jwtDecode<{ role?: string }>(token);
  return claims.role === "admin";
}

That utility will spread across the codebase.

Prefer naming that makes unsafe use obvious:

export function getTokenExpiryHint(token: string): Date | null;
export function getTokenDiagnostics(token: string): TokenDiagnostics;
export function getProvisionalTenantHint(token: string): string | null;

Avoid names like:

isLoggedIn
isAdmin
hasScope
canEdit
canDelete
canApprove

Those names imply authority.


16. Handling malformed tokens

Token parsing can fail.

Malformed token handling must not crash the whole app.

type DecodeResult<T> =
  | { ok: true; claims: T }
  | { ok: false; reason: "missing" | "malformed" };

export function safeDecodeToken<T>(token: string | null): DecodeResult<T> {
  if (!token) return { ok: false, reason: "missing" };

  try {
    return { ok: true, claims: jwtDecode<T>(token) };
  } catch {
    return { ok: false, reason: "malformed" };
  }
}

A malformed access token should usually lead to session recovery:

Malformed token
  -> clear in-memory token
  -> attempt session refresh if refresh channel exists
  -> otherwise mark session anonymous/expired
  -> do not render protected data

Do not show raw parse errors to end users.


17. Token decoding and security-sensitive UX

Some UX decisions are themselves security-sensitive.

Examples:

  • showing admin menus,
  • showing tenant switcher entries,
  • showing case details,
  • enabling approval buttons,
  • showing confidential fields,
  • selecting active organization,
  • deciding whether step-up authentication is required.

For these, prefer server projection.

Security-sensitive UI should be backed by server-issued UI authorization data.

This does not make the frontend an enforcement boundary. It simply prevents unnecessary exposure and misleading affordances.


18. Access token should not be your frontend database

A common anti-pattern is stuffing everything into JWT claims:

{
  "sub": "usr_123",
  "email": "maya@example.com",
  "roles": ["admin", "case_manager", "auditor"],
  "groups": ["group_a", "group_b", "group_c"],
  "permissions": [
    "case.create",
    "case.update",
    "case.delete",
    "case.assign",
    "report.export",
    "tenant.manage"
  ],
  "tenants": ["tenant_a", "tenant_b", "tenant_c"]
}

This creates problems:

  • token becomes large,
  • claims become stale,
  • sensitive relationship data leaks to browser,
  • permission changes require token refresh,
  • resource-level auth cannot fit cleanly,
  • revocation is harder,
  • UI starts depending on token internals,
  • identity provider schema leaks into app architecture.

Better:

Token carries minimal credential claims.
/session returns minimal session projection.
/permissions returns app-specific permission decisions.
Resource APIs enforce current policy.

19. Minimal token claim philosophy

For browser-exposed tokens, prefer minimality.

Useful claims:

iss
sub
aud
exp
iat
nbf
jti or token id when appropriate
scope if using OAuth resource scopes

Be careful with:

email
phone
name
picture
tenant list
group list
role list
permission list
regulatory attributes
case assignment data

Claims are easy to add and hard to remove once frontend code depends on them.

Treat every claim as an API contract.


20. Testing client-side decoding discipline

Test the boundary, not just the parser.

20.1 Parser tests

it("returns null for malformed token", () => {
  expect(decodeAccessTokenUnsafe("not-a-jwt")).toBeNull();
});

20.2 Expiry planning tests

it("plans proactive refresh before token expiry", () => {
  const claims = { exp: 1000 };

  const plan = planRefreshFromDecodedExp(claims, {
    serverTimeOffsetMs: 0,
    refreshBeforeMs: 30_000,
  });

  expect(plan.kind).toBe("refresh-at");
});

20.3 Import boundary tests

Use linting or static analysis to prevent jwtDecode in business components.

src/auth/**        allowed
src/components/**  forbidden
src/features/**    forbidden except auth diagnostics

20.4 Authorization regression tests

Ensure decoded role does not control critical action visibility directly.

Given token role=admin
And server permission says case.delete=false
Then Delete button is hidden or disabled
And DELETE /cases/:id still returns 403

21. Review checklist

Use this checklist in PR review.

Client-side token decoding checklist:

[ ] Is token decoding isolated inside auth/session infrastructure?
[ ] Are business components prevented from importing jwtDecode directly?
[ ] Is decoded data used only as hint, diagnostic, or refresh scheduling input?
[ ] Are role/scope/group claims not used as enforcement authority?
[ ] Are tenant claims not used to enforce tenant boundary?
[ ] Is /session or equivalent used for committed session state?
[ ] Is /permissions or loader data used for security-sensitive UI exposure?
[ ] Does backend still authorize every protected read/write?
[ ] Are decoded claims excluded from logs, analytics, and error breadcrumbs?
[ ] Are malformed tokens handled without rendering protected content?
[ ] Are stale-claim scenarios tested?
[ ] Is token payload minimal and free of unnecessary PII?

22. Mental model summary

Token decoding is like reading a signed badge through a window.

You can see what the badge says. You may use that to route the person to the right desk. But you do not let them open the vault just because the text on the badge says admin.

The secure model is:

Decode for hints.
Validate at trust boundary.
Authorize at resource boundary.
Audit at mutation boundary.

React should understand tokens. It should not worship them.

Lesson Recap

You just completed lesson 17 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.