By AndyPublished
JWT Decoder vs Validator: Understanding the Difference
The Four Steps of JWT Processing
Processing a JWT correctly involves four distinct steps, each building on the previous:
Base64url-decode the header and payload to reveal the JSON. Requires no key. Anyone can do this.
Cryptographically confirm the signature using the correct key. Proves authenticity and integrity.
Check claim values: exp not past, nbf not future, iss matches expected issuer, aud matches your service.
Apply application-specific business logic: does this user have permission for this specific resource and action?
Step 1: Decoding
Decoding a JWT means reversing the Base64url encoding on the header and payload to read the JSON content. This operation:
- ·Requires no key or secret of any kind
- ·Can be done by anyone who has the token string
- ·Reveals all claims in plain text
- ·Provides zero information about whether the token is authentic or valid
Decoding is useful for inspection and debugging, to understand what claims a token contains, check expiry timestamps, or diagnose issues. This is what this tool does instantly when you paste a token. It is not a security operation.
Step 2: Verification
Verification uses cryptography to confirm that:
- ·The token was signed by a party holding the expected key
- ·Neither the header nor the payload has been modified since signing
This requires either the HMAC shared secret (for HS256/384/512) or the issuer's public key (for RS256/ES256/PS256 and their variants). A successful verification means the token was produced by a specific key, but it does not mean the claims within it are currently valid or appropriate for the action being requested.
// Pseudocode, step 2 only
token = get_token_from_request()
payload = jwt.verify(token, public_key, { algorithms: ['RS256'] })
// payload is decoded AND signature-verified
// but exp, iss, aud have NOT been checked yet!Step 3: Claim Validation
After verifying the signature, the verifying service must check the standard claims:
// Step 3: claim validation
now = unix_timestamp()
if (payload.exp && now >= payload.exp) {
throw new Error('Token expired')
}
if (payload.nbf && now < payload.nbf) {
throw new Error('Token not yet valid')
}
if (payload.iss !== EXPECTED_ISSUER) {
throw new Error('Invalid issuer')
}
if (!payload.aud.includes(MY_SERVICE_ID)) {
throw new Error('Invalid audience')
}Most JWT libraries accept configuration options for issuer and audience and will perform these checks automatically. However, always read your library's documentation to confirm which checks are performed by default and which require explicit configuration.
Step 4: Authorization
Authorization is the application-level decision: given a verified, valid token with specific claims, is this user allowed to perform this specific action on this specific resource?
// Step 4: authorization
user_id = payload.sub
resource_id = request.params.id
action = 'delete'
// Check if user has permission for this action
if (!can_perform(user_id, action, resource_id)) {
return 403 Forbidden
}Authorization logic is application-specific and cannot be handled by a JWT library alone. It may involve checking role claims, querying a permissions database, or evaluating fine-grained access control policies. JWT claims (like roles) provide a starting point but should be cross-referenced with authoritative server-side state for sensitive operations, remember that claims reflect the state at token issuance, not necessarily the current state.
Common Mistakes to Avoid
- ·Trusting decoded but unverified claims: Reading the
suborrolesfrom a decoded token without verifying the signature first. An attacker can forge arbitrary claims. - ·Skipping audience validation: Accepting tokens intended for another service. This enables token forwarding attacks.
- ·Not checking exp in custom middleware: Some frameworks and middleware don't check expiry by default. Always confirm your library/framework is enforcing expiry.
- ·Using client-decoded claims for server-side decisions: If a frontend application decodes a JWT and sends the claims to the backend (e.g., in a request body), the backend must not trust those values. Always decode and verify the original token server-side.
- ·Relying on claims for real-time permissions: JWT claims are a snapshot. If you've revoked a user's admin role, existing tokens still show the old role until they expire. For real-time permission checks, query your permissions store directly.
Authorization Patterns with JWT Claims
JWT claims enable several common server-side authorization patterns. Each has different tradeoffs between simplicity, freshness, and query overhead.
Role-Based Access Control (RBAC)
RBAC uses a roles claim (or similar) to assign named roles to a user. The API checks whether the required role is present:
// JWT payload:
// { "sub": "user_123", "roles": ["admin", "editor"] }
function requireRole(role: string) {
return (req, res, next) => {
const roles = req.jwt.payload.roles ?? []
if (!roles.includes(role)) {
return res.status(403).json({ error: 'Insufficient role' })
}
next()
}
}
app.delete('/posts/:id', authenticate, requireRole('admin'), deletePost)RBAC is simple but coarse-grained. A user either has a role or doesn't, there is no per-resource granularity. It works well for small systems with a handful of roles but becomes unwieldy as permissions grow more complex.
Scope-Based Authorization (OAuth 2.0)
OAuth 2.0 access tokens carry a scope claim listing the permissions granted to the client application. The API checks that the required scope is present:
// JWT payload:
// { "sub": "user_123", "scope": "read:posts write:posts" }
function requireScope(scope: string) {
return (req, res, next) => {
const scopes = (req.jwt.payload.scope ?? '').split(' ')
if (!scopes.includes(scope)) {
return res.status(403).json({ error: 'Insufficient scope' })
}
next()
}
}
app.post('/posts', authenticate, requireScope('write:posts'), createPost)Scope-based auth is the standard for OAuth 2.0 APIs. Scopes represent what the client application is allowed to do, not necessarily what the user is allowed to do, the distinction matters when users grant third-party apps limited access.
Attribute-Based Access Control (ABAC)
ABAC makes authorization decisions based on attributes of the user, the resource, and the environment. JWT claims provide the user attributes; resource attributes come from a database query:
// JWT payload:
// { "sub": "user_123", "org_id": "org_456" }
async function canEditPost(userId: string, orgId: string, postId: string) {
const post = await db.posts.findById(postId)
// User must own the post OR be in the same org as the post's owner
return post.authorId === userId || post.orgId === orgId
}
app.put('/posts/:id', authenticate, async (req, res, next) => {
const allowed = await canEditPost(
req.jwt.payload.sub,
req.jwt.payload.org_id,
req.params.id
)
if (!allowed) return res.status(403).json({ error: 'Forbidden' })
next()
})ABAC is more flexible than RBAC but requires database queries on each request. It is appropriate when permissions depend on dynamic resource attributes that cannot be encoded into static role claims at token issuance time.
Framework-Specific Gotchas
Express.js, express-jwt
The express-jwt middleware (v7+) places the decoded payload on req.auth, not req.user. Code written for older versions will silently get undefined from req.user and may fail open if not properly guarded.
By default, express-jwt does not validate iss or aud. Add them explicitly via the issuer and audience options, or validate them manually in subsequent middleware.
FastAPI (Python), python-jose / PyJWT
FastAPI's OAuth2 examples in the documentation use python-jose, which has a history of CVEs. The actively maintained alternative is PyJWT (2.x+). When using PyJWT, the audience parameter must be passed explicitly to jwt.decode(), if it is omitted, audience validation is skipped even if the token contains an aud claim.
Spring Security (Java)
Spring Security's JWT filter is configured via a JwtDecoder bean. The default decoder from NimbusJwtDecoder validates the signature and exp but does not validate iss or aud unless you explicitly add validators via OAuth2TokenValidators. Filter ordering also matters: ensure the JWT filter runs before any authorization filter that reads the security context.
Next.js, middleware token forwarding
Next.js Edge Middleware runs before the request reaches the API route. A common pattern is to verify the JWT in middleware and forward the decoded payload in a custom header to the API route. The API route must not trust these forwarded headers from external requests, strip them at the gateway and only accept them from your own middleware. Never forward Authorization tokens between services using headers set in client-side code.
A Complete Server-Side Example
The following shows a complete, annotated API endpoint handler that correctly applies all four steps of JWT processing. It is written as Node.js/TypeScript pseudocode but the pattern applies to any language.
import jwt from 'jsonwebtoken'
import { getPublicKey } from './jwks' // fetches/caches key by kid
async function handleRequest(req, res) {
// ── Step 1 + 2: Decode and verify the signature ──────────────────
const authHeader = req.headers.authorization ?? ''
if (!authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' })
}
const token = authHeader.slice(7).trim()
let payload
try {
// Decode header without verification to extract kid
const { header } = jwt.decode(token, { complete: true }) ?? {}
const publicKey = await getPublicKey(header?.kid)
// Verify signature with explicit algorithm restriction
payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // never omit, prevents alg confusion
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
// exp and nbf are validated automatically by jsonwebtoken
})
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
return res.status(401).json({ error: err.message })
}
// ── Step 3: Claim validation (issuer/audience done by library) ───
// Application-specific claim checks beyond what the library handles
if (!payload.sub) {
return res.status(401).json({ error: 'Token missing subject' })
}
// ── Step 4: Authorization ─────────────────────────────────────────
const resourceId = req.params.id
const hasPermission = await db.checkPermission(payload.sub, 'read', resourceId)
if (!hasPermission) {
return res.status(403).json({ error: 'Forbidden' })
}
// ── Safe to use payload.sub for business logic ────────────────────
const resource = await db.getResource(resourceId)
return res.json(resource)
}Key points in this example: the algorithm list is explicit, issuer and audience are validated by the library using environment variables (not hardcoded), the kid is used to select the correct public key from JWKS, and the authorization check queries the database rather than trusting a role claim blindly.
Where This Tool Fits
This JWT decoder performs Step 1 (decoding) and optionally Step 2 (signature verification via the Verify tab) entirely in your browser for inspection and debugging purposes. Steps 3 and 4 belong in your application's server-side code.