Centralizing Auth Tokens and Error Handling with AngularJS $http Interceptors
AngularJS $http interceptors let you attach auth tokens and handle 401s in one place — if you scope them with an URL allowlist and propagate rejections correctly. Here's a worked example with tests.
25 Jan 2026, 12:45 UTC

The problem: auth logic scattered across every controller
If you're maintaining an AngularJS 1.x application, you've probably seen this pattern: every service that calls the API manually attaches an Authorization header, and every controller implements its own slightly different handling for 401 responses. One service forgets the header, another redirects to login on a 401 that was actually a permissions issue, and a third retries in a loop until the browser tab dies.
The useful takeaway: AngularJS has a built-in extension point for exactly this. The $http interceptor pipeline lets you attach tokens and centralize error handling in one factory, so controllers and services stay ignorant of auth plumbing. Done carefully — with an URL allowlist and rejection discipline — it removes an entire class of bugs.
One caveat up front: AngularJS reached end of life and no longer receives security fixes. This pattern is for teams maintaining or migrating a legacy app, not for new projects.
How interceptors fit into the $http pipeline
An interceptor is a factory that returns an object with any of four optional methods: request, requestError, response, and responseError. You register it once in a config block, and AngularJS runs it around every $http call in that module:
angular.module('app').config(['$httpProvider', function ($httpProvider) {
$httpProvider.interceptors.push('authInterceptor');
}]);Only implement the methods you need. For token injection plus 401 handling, that's request and responseError. Two rules keep you out of trouble:
- Return
$q.reject(...)from error handlers unless you've genuinely recovered. Swallowing a rejection makes callers think the request succeeded. - Never retry inside
responseErrorwithout a guard. A naive "on 401, refresh the token and re-issue the request" loop will spin forever if the refresh itself returns 401.
Worked example: token injection scoped to /api/
The biggest real-world mistake with global interceptors is attaching credentials to the wrong origin. If your app calls a third-party analytics endpoint, a global interceptor will happily send your bearer token there too. The fix is an explicit allowlist inside the request method:
angular.module('app').factory('authInterceptor',
['$q', '$injector', 'tokenService',
function ($q, $injector, tokenService) {
var API_PREFIX = '/api/';
return {
request: function (config) {
// Only attach credentials to our own API.
if (config.url.indexOf(API_PREFIX) === 0) {
var token = tokenService.getToken();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = 'Bearer ' + token;
}
}
return config;
},
responseError: function (rejection) {
if (rejection.status === 401 &&
rejection.config.url.indexOf(API_PREFIX) === 0 &&
!rejection.config._authRedirected) {
// Mark the config so a retried request can't loop here.
rejection.config._authRedirected = true;
// Use $injector to avoid a circular dependency on $state/$location.
$injector.get('$window').location.href = '/login';
}
// Always propagate the failure so callers can react.
return $q.reject(rejection);
}
};
}]);
Notes on the details that matter:
- The URL check uses a prefix match on
/api/. Login and static assets are excluded, so you never leak a token to a request that doesn't need it. If your API lives on another origin, match the full origin string instead. $injector.get('$window')avoids a circular dependency:$httpdepends on interceptors, so injecting services that themselves use$httpdirectly into the factory will fail at bootstrap.- The
_authRedirectedflag on the config object is the loop guard. If some other code retries the same request config, the interceptor won't redirect a second time.
Testing it without touching controllers
Interceptors are one of the more testable parts of AngularJS because $httpBackend (from ngMock) lets you assert on headers and simulate failures. In a Jasmine spec, run inside your test runner with ngMock loaded:
describe('authInterceptor', function () {
var $http, $httpBackend;
beforeEach(module('app', function ($provide) {
$provide.factory('tokenService', function () {
return { getToken: function () { return 'test-token'; } };
});
}));
beforeEach(inject(function (_$http_, _$httpBackend_) {
$http = _$http_;
$httpBackend = _$httpBackend_;
}));
it('adds Authorization to /api/ requests', function () {
$httpBackend.expectGET('/api/users', function (headers) {
return headers.Authorization === 'Bearer test-token';
}).respond(200, []);
$http.get('/api/users');
$httpBackend.flush();
});
it('does not add Authorization to /auth/login', function () {
$httpBackend.expectPOST('/auth/login', undefined, function (headers) {
return !headers.Authorization;
}).respond(200, {});
$http.post('/auth/login', {});
$httpBackend.flush();
});
it('rejects on 401 so callers still see the failure', function () {
$httpBackend.expectGET('/api/users').respond(401, {});
var failed = false;
$http.get('/api/users').catch(function () { failed = true; });
$httpBackend.flush();
expect(failed).toBe(true);
});
});
The third test is the one people skip, and it's the one that catches a swallowed rejection.
Trade-offs and limitations
Global interception is simple, but it is global. Every $http call — including ones from third-party AngularJS libraries you didn't write — flows through your interceptor. The URL allowlist mitigates this, but it's a convention, not a guarantee; a future developer who adds a new API path has to know to update the prefix logic.
Token refresh is the other sharp edge. If you extend responseError to refresh an expired token and replay the original request, you must queue concurrent requests while the refresh is in flight and cap retries, or you'll get stampeding refresh calls and infinite loops. For a legacy app in maintenance mode, redirecting to login on 401 (as above) is often the more honest engineering decision.
Finally, behavior details vary slightly across AngularJS 1.x minor versions — check your exact version in package.json or bower.json and confirm interceptor registration order if you have more than one.
Verify it in the running app
After wiring the interceptor, open browser dev tools, sign in, and confirm three things: requests under /api/ carry the Authorization header, requests to your login endpoint and any third-party origins do not, and forcing a 401 (for example by deleting the token server-side) produces exactly one redirect and a rejected promise in the calling code — not a retry storm. If all three hold, your auth plumbing is centralized and your controllers can go back to doing their actual jobs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.