Header Ads

Getting “401 Unauthorized” or errors like “jwt expired”, “jwt malformed” and “invalid signature” in your Angular, Ionic or Node.js app

Getting “401 Unauthorized” or errors like “jwt expired”, “jwt malformed” and “invalid signature” in your Angular, Ionic or Node.js app? This guide shows how to find the cause and fix it in 8 steps, with working code for the Angular interceptor and the Express middleware.

Your login works, you get a token back, and then every protected request fails. The browser console shows this:

GET http://localhost:3000/api/profile 401 (Unauthorized)

And your Node.js server logs one of these messages (they come from the popular jsonwebtoken package):

JsonWebTokenError: jwt must be provided
JsonWebTokenError: jwt malformed
JsonWebTokenError: invalid signature
TokenExpiredError: jwt expired

Each message points to a different cause, so the quickest fix is to read the message first and then follow the matching step below.

401 vs 403: What Is the Difference?

  • 401 Unauthorized means the server does not know who you are: the token is missing, expired or invalid.
  • 403 Forbidden means the server knows who you are, but you are not allowed to do this, for example a normal user calling an admin route.

This post is about 401 errors. If you get a 403, check your role or permission logic instead of your token handling.


Step 1: Read the Exact Error Message

Look at your server log and match the message to its meaning:

Message or symptomWhat it meansGo to
jwt must be providedNo token reached jwt.verifySteps 2, 3 and 4
jwt malformedThe token is not a valid three-part string, often because of extra quotes or a doubled prefixStep 3
invalid signatureThe token was signed with a different secret than the one used to verify itStep 5
jwt expiredThe token’s expiry time has passedStep 6
401 in the browser, works in PostmanInterceptor not registered, or CORS blocks the headerSteps 2 and 4
Works, then fails after the app restartsThe token is not being stored or read correctlyStep 7


Step 2: Confirm the Token Is Actually Sent

Open DevTools, go to the Network tab, click the failing request and look at Request Headers. You should see a header like this:

Authorization: Bearer eyJhbGciOi...

If the header is missing, your Angular code is not adding it. The cleanest fix is an HTTP interceptor that adds it to every request. In Angular 15 and newer, use a functional interceptor:

import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('access_token');

if (!token) {
return next(req);
}

return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
}));
};

Then register it where you provide HttpClient. In a standalone Angular or Ionic app, that is app.config.ts or main.ts:

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';

export const appConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor]))
]
};

Writing the interceptor is not enough: a very common mistake is forgetting to register it. If your project still uses the older class-based interceptors, register them with the HTTP_INTERCEPTORS token and multi: true.


Step 3: Fix the Bearer Format and Stray Quotes

Your server expects exactly Bearer, one space, then the token. Two small bugs cause most “jwt malformed” errors. First, saving the token with JSON.stringify, which adds quote characters:

// wrong: stores "eyJhbGci..." with the quotes included
localStorage.setItem('access_token', JSON.stringify(token));

// right
localStorage.setItem('access_token', token);

Second, adding the word Bearer twice, once when saving the token and again in the interceptor. Log the header on the server to see what really arrives:

console.log(JSON.stringify(req.headers.authorization));


Step 4: Make Sure CORS Allows the Authorization Header

Sending an Authorization header makes the browser send a preflight OPTIONS request first. If your server does not list that header, the browser blocks the real request, and your API may never see a token at all. Check that your CORS setup includes it:

app.use(cors({
origin: ['http://localhost:8100', 'http://localhost:4200'],
allowedHeaders: ['Content-Type', 'Authorization']
}));

I explain the full CORS setup, including Capacitor origins, in my guide on fixing CORS errors in Node.js, Angular and Ionic.


Step 5: Fix invalid signature (Secret Mismatch)

“invalid signature” almost always means the secret used to sign the token is different from the one used to verify it. Typical reasons:

  • the .env file is loaded on your laptop but not on the server or in Docker,
  • your process manager started the app without the environment variables,
  • you changed the secret after issuing tokens, so old tokens are now invalid,
  • two services use different secrets for the same token.

Make sure signing and verifying read the same variable, and that it is set in every environment:

const jwt = require('jsonwebtoken');

// when logging in
const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, {
expiresIn: '15m'
});

If process.env.JWT_SECRET is undefined, jwt.sign throws an error about a missing secret. Treat that as a configuration problem, never fall back to a hard-coded secret in code.


Step 6: Handle Expired Tokens (jwt expired)

Tokens are meant to expire. If you see jwt expired, decode the token locally to check its expiry. This command only decodes it and does not verify it. Avoid pasting real production tokens into online tools:

node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString()))" YOUR_TOKEN

The exp value is in seconds since 1970. To read it as a date, use new Date(exp * 1000). If tokens expire too quickly while you are testing, increase expiresIn in development. For production, a common pattern is a short-lived access token (for example 15 minutes) plus a longer-lived refresh token that gets a new access token when the first one expires.

If you add refresh logic to your interceptor, retry the failed request only once, otherwise a bad refresh token can trap the app in an endless loop.


Step 7: Fix Token Storage in Ionic and Capacitor

In a browser, localStorage is fine for testing, but on a phone the operating system may clear web storage, so users get logged out unexpectedly. In Capacitor apps, use the Preferences plugin:

npm install @capacitor/preferences
npx cap sync
import { Preferences } from '@capacitor/preferences';

await Preferences.set({ key: 'access_token', value: token });

const { value } = await Preferences.get({ key: 'access_token' });

Note that Preferences methods are asynchronous, so your interceptor must wait for the value before sending the request. Preferences is not encrypted storage. For sensitive tokens, consider a secure storage plugin that uses the iOS Keychain and Android Keystore.


Step 8: Use a Clean Express Middleware

Once the client side is correct, make your server middleware return clear messages. This also makes the next debugging session much faster:

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');

if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ message: 'Missing or malformed Authorization header' });
}

try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
return next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired' });
}
return res.status(401).json({ message: 'Invalid token' });
}
}

app.get('/api/profile', authenticate, (req, res) => {
res.json({ userId: req.user.sub });
});

If your server and your phone clocks are slightly out of sync, jwt.verify accepts a clockTolerance option (in seconds) to allow a small difference.


Security Notes

  • Keep secrets in environment variables, never in your Git repository or your Angular code.
  • Use short expiry times for access tokens, and use HTTPS in production.
  • Never log full tokens in production, because they work like passwords until they expire.
  • Do not store sensitive data in the token payload. Anyone can decode it, even though they cannot change it.


Debug Checklist

  1. Read the server error message and match it to Step 1.
  2. Check the Network tab: is the Authorization header present?
  3. Check that the value is exactly Bearer, a space, then the token, with no quotes.
  4. Check the preflight OPTIONS request status and headers.
  5. Confirm JWT_SECRET is the same where tokens are signed and verified.
  6. Decode the token and check the exp time.


Conclusion

Most 401 problems come from one of five places: the interceptor is not registered, the header format is wrong, CORS blocks the header, the secret differs between environments, or the token has expired. Read the exact message from jsonwebtoken, follow the matching step, and return clear error messages from your middleware so the next bug is easier to find.

Related Guides

Post a Comment

0 Comments