← Back to Articles         
How would you refresh an expired access token?

How would you refresh an expired access token?

Published on 02 Oct 2026     8 min read Angular
Scenario Based Questions

These two questions are closely related. In an Angular + ASP.NET Core JWT application, the usual design is:

  • Access token → short-lived and sent with API requests.
  • Refresh token → longer-lived and used only to obtain a new access token.
  • When the API returns 401, Angular can attempt a refresh and retry the failed request.
  • If several requests fail at the same time, Angular should allow only one refresh request and make the others wait for its result.

1. How would you refresh an expired access token?

What is token refresh?

Suppose the user logs in and receives:

{
  "accessToken": "eyJ...",
  "refreshToken": "abc123..."
}

Later:

Access Token
   ↓
Expires
   ↓
Angular calls API
   ↓
401 Unauthorized
   ↓
Interceptor detects 401
   ↓
Call /api/auth/refresh
   ↓
ASP.NET Core validates refresh token
   ↓
Returns new access token
   ↓
Angular stores new token
   ↓
Retry original request

This allows the user's authenticated session to continue without requiring another login every time the short-lived access token expires.


Simple Angular Implementation

auth.service.ts

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';

export interface TokenResponse {
  accessToken: string;
  refreshToken: string;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private http = inject(HttpClient);

  private readonly refreshUrl =
    'https://localhost:7001/api/auth/refresh';

  getAccessToken(): string | null {
    return localStorage.getItem('access_token');
  }

  getRefreshToken(): string | null {
    return localStorage.getItem('refresh_token');
  }

  refreshToken(): Observable<TokenResponse> {

    const refreshToken = this.getRefreshToken();

    return this.http.post<TokenResponse>(
      this.refreshUrl,
      { refreshToken }
    ).pipe(
      tap(response => {
        localStorage.setItem(
          'access_token',
          response.accessToken
        );

        localStorage.setItem(
          'refresh_token',
          response.refreshToken
        );
      })
    );
  }

  logout(): void {
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }
}

This uses localStorage to keep the sample simple. Production applications should choose token storage based on their threat model; refresh tokens are commonly protected with secure, HttpOnly cookies rather than exposed to JavaScript.


Interceptor

The interceptor performs two jobs:

  1. Attach the access token.
  2. If the API returns 401, refresh and retry.
import { inject } from '@angular/core';
import {
  HttpErrorResponse,
  HttpInterceptorFn
} from '@angular/common/http';

import {
  catchError,
  switchMap,
  throwError
} from 'rxjs';

import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn =
  (req, next) => {

    const authService = inject(AuthService);

    const token = authService.getAccessToken();

    let authRequest = req;

    if (token) {
      authRequest = req.clone({
        setHeaders: {
          Authorization: `Bearer ${token}`
        }
      });
    }

    return next(authRequest).pipe(

      catchError((error: HttpErrorResponse) => {

        if (error.status === 401) {

          return authService.refreshToken().pipe(

            switchMap(response => {

              const retryRequest = req.clone({
                setHeaders: {
                  Authorization:
                    `Bearer ${response.accessToken}`
                }
              });

              return next(retryRequest);
            }),

            catchError(refreshError => {
              authService.logout();

              return throwError(
                () => refreshError
              );
            })
          );
        }

        return throwError(() => error);
      })
    );
  };

Register it:

provideHttpClient(
  withInterceptors([
    authInterceptor
  ])
);

Problem with this implementation

Suppose the page sends three requests:

GET /products
GET /categories
GET /profile

The access token has expired.

All three may receive:

401 Unauthorized

The naive interceptor then does this:

Products 401
    ↓
POST /refresh

Categories 401
    ↓
POST /refresh

Profile 401
    ↓
POST /refresh

Now we have three simultaneous refresh requests.

That is undesirable, and it can be especially problematic if the backend uses refresh-token rotation, where a refresh token becomes invalid after being used.

That leads directly to your second question.


2. How would you prevent multiple simultaneous refresh-token requests?

The idea is:

Only the first 401 should start the refresh. Other failed requests should wait for the same refresh result.

A clean RxJS solution is to keep one shared refresh Observable.


Recommended Pattern

auth.service.ts

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import {
  Observable,
  finalize,
  shareReplay,
  tap
} from 'rxjs';

export interface TokenResponse {
  accessToken: string;
  refreshToken: string;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private http = inject(HttpClient);

  private refreshRequest$:
    Observable<TokenResponse> | null = null;

  private readonly refreshUrl =
    'https://localhost:7001/api/auth/refresh';

  getAccessToken(): string | null {
    return localStorage.getItem('access_token');
  }

  getRefreshToken(): string | null {
    return localStorage.getItem('refresh_token');
  }

  refreshToken(): Observable<TokenResponse> {

    // A refresh is already running.
    // Return the same Observable.
    if (this.refreshRequest$) {
      return this.refreshRequest$;
    }

    const refreshToken = this.getRefreshToken();

    this.refreshRequest$ =
      this.http.post<TokenResponse>(
        this.refreshUrl,
        { refreshToken }
      ).pipe(

        tap(response => {

          localStorage.setItem(
            'access_token',
            response.accessToken
          );

          localStorage.setItem(
            'refresh_token',
            response.refreshToken
          );

        }),

        finalize(() => {
          this.refreshRequest$ = null;
        }),

        shareReplay(1)
      );

    return this.refreshRequest$;
  }

  logout(): void {

    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }
}

The important pieces are:

private refreshRequest$:
    Observable<TokenResponse> | null = null;

and:

if (this.refreshRequest$) {
    return this.refreshRequest$;
}

If a refresh is already executing, another request does not create another HTTP refresh call.

It subscribes to the existing refresh operation.


Interceptor

import { inject } from '@angular/core';

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

import {
  catchError,
  switchMap,
  throwError
} from 'rxjs';

import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn =
  (req, next) => {

    const authService = inject(AuthService);

    const token = authService.getAccessToken();

    const authRequest = token
      ? req.clone({
          setHeaders: {
            Authorization: `Bearer ${token}`
          }
        })
      : req;

    return next(authRequest).pipe(

      catchError((error: HttpErrorResponse) => {

        if (error.status !== 401) {
          return throwError(() => error);
        }

        // Important:
        // Don't try to refresh when the refresh
        // endpoint itself returns 401.

        if (req.url.includes('/api/auth/refresh')) {
          authService.logout();

          return throwError(() => error);
        }

        return authService.refreshToken().pipe(

          switchMap(response => {

            const retryRequest = req.clone({
              setHeaders: {
                Authorization:
                  `Bearer ${response.accessToken}`
              }
            });

            return next(retryRequest);
          }),

          catchError(refreshError => {

            authService.logout();

            return throwError(
              () => refreshError
            );
          })
        );
      })
    );
  };

What happens with 3 simultaneous 401s?

Suppose:

Request A → 401
Request B → 401
Request C → 401

Request A

It reaches:

authService.refreshToken()

Since:

refreshRequest$ === null

it creates:

POST /api/auth/refresh

Request B

A moment later, B also reaches:

authService.refreshToken()

But now:

refreshRequest$ !== null

Therefore:

if (this.refreshRequest$) {
    return this.refreshRequest$;
}

B waits for the same refresh operation.


Request C

The same thing happens.

It also waits for:

refreshRequest$

So the actual flow becomes:

Request A ──401──┐
                 │
Request B ──401──┼──► ONE refresh request
                 │
Request C ──401──┘
                       │
                       ▼
               New Access Token
                       │
            ┌──────────┼──────────┐
            ▼          ▼          ▼
         Retry A    Retry B    Retry C

Only one request goes to:

POST /api/auth/refresh

Why shareReplay(1)?

This is a key part:

shareReplay(1)

Without sharing, multiple subscriptions to a cold HttpClient Observable can trigger multiple HTTP calls.

shareReplay(1) makes the subscribers share the same refresh operation and makes the latest successful response available to those subscribers.

Conceptually:

                 Refresh HTTP call
                       │
                       ▼
                  New JWT Token
                       │
             shareReplay(1)
              /       |       \
             /        |        \
       Request A  Request B  Request C

Why finalize()?

After the refresh succeeds or fails:

finalize(() => {
  this.refreshRequest$ = null;
})

This clears the current refresh operation.

Therefore, a future token expiration can start a new refresh request.

First expiration
      ↓
refreshRequest$ created
      ↓
Refresh completed
      ↓
refreshRequest$ = null

Later...

Second expiration
      ↓
New refreshRequest$ created

Important Production Considerations

You should also consider these points.

Don't refresh every 401

A 401 doesn't always mean:

"The access token expired."

It could also mean that credentials are absent, malformed, revoked, or otherwise invalid.

A robust design should have a clear backend/client contract for when refresh is appropriate.

Don't intercept the refresh endpoint recursively

Otherwise:

Refresh request
     ↓
401
     ↓
Interceptor
     ↓
Refresh request
     ↓
401
     ↓
Interceptor
     ↓
...

So exclude it:

if (req.url.includes('/api/auth/refresh')) {
    authService.logout();
    return throwError(() => error);
}

Refresh failure should end the session

If the refresh token is expired/revoked:

Access Token expired
       ↓
Refresh attempted
       ↓
Refresh Token invalid/expired
       ↓
Logout / clear authentication state
       ↓
Redirect to login

Consider refresh-token rotation

The backend can issue:

Old Refresh Token
       ↓
Used once
       ↓
Invalidated
       ↓
New Refresh Token issued

That's another reason preventing simultaneous refresh requests is important.


Key Points

  • Use an HTTP interceptor to detect authentication failures and retry requests.
  • Send the refresh token to a dedicated refresh endpoint.
  • Store the newly issued access token before retrying.
  • Retry the original request using the new access token.
  • Never let the refresh endpoint recursively trigger another refresh.
  • If refresh fails, clear the authenticated session and normally return the user to login.
  • Prevent concurrent refreshes by sharing one in-flight refresh Observable.
  • shareReplay(1) allows concurrent subscribers to share the same refresh operation.
  • finalize() resets the refresh state after success or failure.

Interview Answers

Q1. How would you refresh an expired JWT access token?

When an authenticated API request indicates that the access token can no longer be used, an Angular interceptor can call the refresh-token endpoint. After receiving a new access token, it stores the token, clones the failed request with the new Authorization: Bearer header, and retries it using switchMap().

Q2. How would you prevent multiple simultaneous refresh-token requests?

Keep a reference to the current refresh Observable. When the first 401 starts a refresh, subsequent 401 requests subscribe to the same shared Observable rather than creating new refresh calls. shareReplay(1) can share the result, and finalize() clears the in-flight reference when the refresh completes.

← Back to Articles