HIGH unicode normalizationfeathersjsjwt tokens

Unicode Normalization in Feathersjs with Jwt Tokens

Unicode Normalization in Feathersjs with Jwt Tokens — how this specific combination creates or exposes the vulnerability

Unicode normalization inconsistencies between JWT handling and FeathersJS service logic can lead to authentication bypass or IDOR-like confusion. When a FeathersJS service validates JWTs, it typically decodes the payload and may use claims such as sub, email, or roles to identify the actor. If the service normalizes these string values differently than the identity provider that issued the token, an attacker can supply a semantically equivalent but differently encoded string (e.g., precomposed vs decomposed Unicode) that results in a different byte sequence after normalization. This mismatch can allow an attacker to present a token issued for one canonical identity while the application compares against a normalized representation, potentially authenticating as a different user or escalating privileges.

Additionally, attackers can embed homoglyphs or variation selectors in user-supplied identifiers (such as username or email claims) that appear visually identical but have distinct code points. If FeathersJS normalizes incoming data to NFC while the JWT issuer uses NFD (or vice versa), comparisons may incorrectly match or fail to match, bypassing intended access controls. This is particularly relevant when endpoints rely on string equality checks rather than canonical forms before verifying scope or role claims. For example, an email claim like usé[email protected] (using Latin small letter e with acute) could normalize differently depending on how FeathersJS processes query parameters, route IDs, or payload fields that reference the same identity.

The risk is realized when the application does not enforce a consistent normalization form across all identity inputs: incoming HTTP headers, JWT claims, query parameters, and stored identifiers. Attack patterns include crafting tokens with carefully chosen Unicode representations to exploit comparison logic, bypassing authorization checks tied to roles or scopes, and leveraging normalization discrepancies to trigger IDOR-like behavior across users who appear identical after normalization. Because FeathersJS may compose URLs or resource identifiers from JWT claims, inconsistent normalization can also facilitate SSRF or injection-like confusion when endpoints are derived from user-controlled identity strings.

Jwt Tokens-Specific Remediation in Feathersjs — concrete code fixes

Apply a canonical Unicode normalization form consistently before comparisons and before using JWT claims to derive access decisions. Use a dedicated normalization step for all identity-bearing strings, including claims such as sub, email, username, and any scope or role values. Store and compare normalized forms, and ensure that normalization occurs both when ingesting JWTs and when querying data stores.

import unicodeNormalize from 'unicode-normalize';

// Normalize to NFC (recommended for consistency)
const normalize = (value) => unicodeNormalize(value, 'NFC');

// Example FeathersJS hook to canonicalize JWT claims
const normalizeJwtClaims = (context) => {
  if (context.params && context.params.user) {
    const user = context.params.user;
    if (user.email) user.email = normalize(user.email);
    if (user.sub) user.sub = normalize(user.sub);
    if (user.username) user.username = normalize(user.username);
  }
  return context;
};

// Apply the hook before authentication/authorization logic
app.hooks.push({ type: 'before', hook: normalizeJwtClaims });

When validating tokens, decode the JWT without verification first to inspect claims, normalize relevant fields, then proceed with verification and matching. Below is a complete example using jsonwebtoken and FeathersJS authentication hooks:

const jwt = require('jsonwebtoken');
const unicodeNormalize = require('unicode-normalize')

const normalize = (value) => unicodeNormalize(value, 'NFC');

const authenticateJwt = (context) => {
  const { accessToken } = context.params.query || {};
  if (!accessToken) { throw new Error('Missing access token'); }

  // Decode without verification to normalize claims
  const decoded = jwt.decode(accessToken, { complete: true });
  if (!decoded || !decoded.header || !decoded.payload) {
    throw new Error('Invalid token format');
  }

  // Normalize identity claims before comparison
  const normalizedPayload = {
    ...decoded.payload,
    sub: normalize(decoded.payload.sub || ''),
    email: normalize(decoded.payload.email || ''),
  };

  // Re-sign the normalized payload for internal use (do NOT re-sign for verification)
  // Instead, compare normalized values against your user store
  const normalizedSub = normalizedPayload.sub;
  const normalizedEmail = normalizedPayload.email;

  // Fetch user using normalized identifiers
  return context.app.service('users').find({
    query: {
      $or: [
        { sub: normalizedSub },
        { email: normalizedEmail }
      ]
    }
  }).then(users => {
    if (!users.data.length) { throw new Error('User not found'); }
    const user = users.data[0];
    // Verify the token against the stored public key or secret using the original token
    return jwt.verify(accessToken, process.env.JWT_PUBLIC_KEY || process.env.JWT_SECRET);
  }).then(payload => {
    context.params.user = user;
    return context;
  });
};

// Use in an authentication hook
app.use('/api/secure', {
  before: {
    all: [authenticateJwt]
  }
});

For route parameters and query strings that reference users or resources, normalize incoming identifiers before lookup:

app.service('tickets').hooks({
  before: {
    get: [async context => {
      const id = normalize(context.id);
      context.params.query._id = id;
      return context;
    }],
    find: [async context => {
      if (context.params.query.assignee) {
        context.params.query.assignee = normalize(context.params.query.assignee);
      }
      return context;
    }]
  }
});

These steps ensure that identity comparisons are performed on canonical forms, mitigating Unicode-based confusion between visually equivalent strings and reducing the risk of authentication or authorization flaws when JWT claims interact with FeathersJS service logic.

Frequently Asked Questions

Does normalizing JWT claims in FeathersJS affect token validity or require re-signing?
No. Normalization is applied only for comparisons and lookups; the original JWT must be verified with the issuer's key without altering the token's signature. Do not re-sign tokens based on normalized claims.
Which Unicode form should I choose for FeathersJS services handling JWTs?
Use NFC for consistency across storage, JWT claims, and runtime comparisons. Ensure the same form is used when issuing tokens and when validating or querying identities.