DEV Community

harshvardhan
harshvardhan

Posted on

Building an Authentication Service in Angular with Signals, JWT & Refresh Tokens

Authentication is one of the most common requirements in modern web applications.

When building an Angular frontend with an ASP.NET Core Web API, we need a way to:

Login users
Register users
Logout users
Store authentication information
Restore authentication after page refresh
Refresh expired access tokens
Check whether a user is authenticated
Read information from a JWT
Determine the user's role

In this article, I'll walk through the AuthService I use in Angular and explain how each part works.

  1. Creating the Authentication Service

The authentication logic is kept inside a dedicated Angular service.

Instead of making HTTP requests directly from components, components communicate with AuthService.

This keeps authentication logic centralized and prevents different components from implementing their own authentication logic.

  1. Injecting HttpClient

The first dependency is Angular's HttpClient.

http = inject(HttpClient);

  1. Managing the Current User with Signals

One of the most useful parts of this service is the signal used to store the authentication state.

currentUserSignal = signal(null);

The signal initially contains:

null

because no user is authenticated when the application starts.

After successful login, it contains the authentication response.

For example:

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

The signal provides a reactive way of tracking authentication state.

  1. Exposing a Readonly Signal

I don't want other components to directly modify the authentication signal.

Therefore, I expose a readonly version:

public currentUser = this.currentUserSignal.asReadonly();

Now components can read the authentication state:

authService.currentUser()

but they cannot do:

authService.currentUser.set(...)

This is important because the authentication service should remain responsible for changing authentication state.

  1. Login

The login method sends the user's credentials to the backend.

login(data: loginDto) {
return this.http.post(
${enviroment.apiUrl}/Auth/Login,
data
);
}

The loginDto might look like:

interface loginDto {
email: string;
password: string;
}

And the backend returns an authentication response:

interface authResponse {
accessToken: string;
refreshToken: string;
}

The important thing here is that the service doesn't subscribe to the HTTP request.

It simply returns the observable.

The component can then decide what to do with the response.

For example:

this.authService.login(this.form.value).subscribe({
next: (response) => {
this.authService.setUser(response);
}
});

  1. Why Return the Observable?

A common mistake is subscribing inside the service:

login(data: loginDto) {

this.http.post(...).subscribe(response => {
    // ...
});
Enter fullscreen mode Exit fullscreen mode

}

This makes the service responsible for the subscription and makes it harder for components to react to success or failure.

Instead, returning the observable:

return this.http.post(...);

allows the caller to decide how to handle it.

This gives us more flexibility.

  1. Storing Authentication Information

After successful login, I use:

setUser(data: authResponse) {
localStorage.setItem(
'auth',
JSON.stringify(data)
);

this.currentUserSignal.set(data);
Enter fullscreen mode Exit fullscreen mode

}

Two things happen here.

First: Store the data
localStorage.setItem(
'auth',
JSON.stringify(data)
);

This allows the authentication information to survive a browser refresh.

Second: Update the signal
this.currentUserSignal.set(data);

This immediately updates Angular's reactive state.

So after login:

Login successful

setUser()

localStorage updated

Signal updated

Components react

  1. Why Use localStorage?

Angular signals only exist in memory.

If the browser page is refreshed:

Signal

Destroyed

The signal goes back to:

null

But localStorage survives page refreshes.

Therefore, we can use localStorage as persistent client-side storage and the signal as the reactive application state.

         localStorage
              │
              │
              ▼
         AuthService
              │
              ▼
          Signal
              │
              ▼
         Components
Enter fullscreen mode Exit fullscreen mode

However, storing JWTs in localStorage has security trade-offs. If an application has an XSS vulnerability, JavaScript running in the page can potentially read localStorage. For higher-security applications, an HTTP-only secure cookie approach can be preferable.

  1. Restoring Authentication After Refresh

Since the signal is reset after a page refresh, we need to restore the stored authentication state.

That's what restoreUser() does:

restoreUser() {
const stored = localStorage.getItem('auth');

if (stored) {
    this.currentUserSignal.set(
        JSON.parse(stored)
    );
}
Enter fullscreen mode Exit fullscreen mode

}

The process is:

Application starts

restoreUser()

Read localStorage

Authentication found?

Yes

Update signal

This method can be called when the application starts.

For example, it can be called from the root component or an application initialization mechanism.

  1. Clearing Authentication During Logout

Logout is the opposite of login.

clearUser() {
localStorage.removeItem('auth');
this.currentUserSignal.set(null);
}

This removes the authentication information from both places:

localStorage
localStorage.removeItem('auth');
Signal
this.currentUserSignal.set(null);

The backend logout request is separate:

logout() {
return this.http.post(
${enviroment.apiUrl}/Auth/Logout,
{}
);
}

A component can first call the backend logout endpoint and then clear the local authentication state.

  1. Checking Authentication Status

Instead of repeatedly checking:

if (this.authService.currentUser() !== null)

I created a helper method:

isAuthenticated() {
return this.currentUser() !== null;
}

Now components and route guards can simply use:

if (this.authService.isAuthenticated()) {
// authenticated
}

This also makes the code easier to read.

  1. Refresh Tokens

JWT authentication commonly uses two tokens:

Access Token
Refresh Token

The access token is short-lived and is sent with API requests.

The refresh token is used to obtain a new access token when the old one expires.

The service contains:

refreshToken() {
const stored = localStorage.getItem('auth');

if (!stored) {
    throw new Error(`auth doesn't exist`);
}

const tokens = JSON.parse(stored);

return this.http.post(
    `${enviroment.apiUrl}/Auth/Refresh`,
    {
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken
    }
);
Enter fullscreen mode Exit fullscreen mode

}

The stored tokens are retrieved:

const stored = localStorage.getItem('auth');

Then parsed:

const tokens = JSON.parse(stored);

Finally, both tokens are sent to the backend:

{
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken
}

The backend validates the refresh token and returns a new authentication response.

  1. How Refresh Token Flow Works

The overall process looks like this:

Angular

│ Request with access token

ASP.NET Core API

│ Access token expired

401 Unauthorized


Angular Interceptor

│ Send refresh token

/Auth/Refresh


New access token


Retry original request

This is normally implemented using an HTTP interceptor.

For example:

API Request

Interceptor

Attach Access Token

API

401?

Refresh Token

Retry Request

This means the user doesn't necessarily have to log in again every time the access token expires.

  1. Decoding the JWT

JWTs contain claims.

For example, a decoded JWT might contain:

{
"nameid": "123",
"unique_name": "John",
"email": "john@example.com",
"role": "Admin"
}

The service provides:

getDecodedToken(): any | null {
const user = this.currentUser();

if (!user) return null;

try {
    return jwtDecode(user.accessToken);
} catch {
    return null;
}
Enter fullscreen mode Exit fullscreen mode

}

The jwtDecode() function decodes the JWT payload.

It is important to understand that decoding a JWT is not the same as validating it.

The Angular application should not trust a decoded token for security decisions on the backend.

The backend must always validate the JWT.

The frontend can use the decoded information for UI purposes such as:

Show Admin Dashboard
Show Employee Menu
Hide Admin Button
Display Username

But actual authorization must be enforced by the API.

  1. Getting the User's Role

Since the role is stored inside the JWT claims, we can create a helper:

getRole(): string | null {
const decoded = this.getDecodedToken();

if (!decoded) return null;

return decoded.role ?? null;
Enter fullscreen mode Exit fullscreen mode

}

Now a component can simply do:

const role = this.authService.getRole();

For example:

if (this.authService.getRole() === 'Admin') {
// show admin UI
}

This becomes particularly useful when implementing role-based navigation.

  1. Example Role-Based UI

For example:

const role = this.authService.getRole();

if (role === 'Admin') {
// Admin navigation
}

if (role === 'Employee') {
// Employee navigation
}

if (role === 'Customer') {
// Customer navigation
}

However, this should only control the frontend experience.

The backend should still protect the endpoint:

[Authorize(Roles = "Admin")]
[HttpGet("users")]
public async Task GetUsers()
{
// ...
}

This gives us two layers:

Frontend

Better user experience

Hide/show UI

Backend

Actual security

Authorize requests

Never rely on Angular alone for authorization.

  1. The Complete AuthService

Putting everything together:

import { HttpClient } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { jwtDecode } from 'jwt-decode';

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

http = inject(HttpClient);

currentUserSignal =
    signal<authResponse | null>(null);

public currentUser =
    this.currentUserSignal.asReadonly();


login(data: loginDto) {
    return this.http.post<authResponse>(
        `${enviroment.apiUrl}/Auth/Login`,
        data
    );
}


logout() {
    return this.http.post(
        `${enviroment.apiUrl}/Auth/Logout`,
        {}
    );
}


register(data: registerDto) {
    return this.http.post<authResponse>(
        `${enviroment.apiUrl}/Auth/Register`,
        data
    );
}


refreshToken() {

    const stored =
        localStorage.getItem('auth');

    if (!stored) {
        throw new Error('auth doesn\'t exist');
    }

    const tokens = JSON.parse(stored);

    return this.http.post(
        `${enviroment.apiUrl}/Auth/Refresh`,
        {
            accessToken: tokens.accessToken,
            refreshToken: tokens.refreshToken
        }
    );
}


restoreUser() {

    const stored =
        localStorage.getItem('auth');

    if (stored) {
        this.currentUserSignal.set(
            JSON.parse(stored)
        );
    }
}


setUser(data: authResponse) {

    localStorage.setItem(
        'auth',
        JSON.stringify(data)
    );

    this.currentUserSignal.set(data);
}


clearUser() {

    localStorage.removeItem('auth');

    this.currentUserSignal.set(null);
}


isAuthenticated() {

    return this.currentUser() !== null;
}


getDecodedToken(): any | null {

    const user = this.currentUser();

    if (!user) return null;

    try {
        return jwtDecode(user.accessToken);
    }
    catch {
        return null;
    }
}


getRole(): string | null {

    const decoded =
        this.getDecodedToken();

    if (!decoded) return null;

    return decoded.role ?? null;
}
Enter fullscreen mode Exit fullscreen mode

}

  1. Authentication State Architecture

The most important concept in this service is that there are two layers of state.

Persistent state
localStorage

This survives browser refreshes.

Reactive state
Angular Signal

This allows components to react immediately when authentication changes.

Together:

         Login
           │
           ▼
     API returns JWT
           │
           ▼
      setUser()
      /        \
     /          \
    ▼            ▼
Enter fullscreen mode Exit fullscreen mode

localStorage Signal
│ │
│ ▼
│ Components


Browser refresh


restoreUser()


Signal

This is a simple but effective pattern for managing authentication state in Angular.

  1. What I Like About This Approach

There are several advantages to keeping authentication inside one service.

Centralized Authentication

All authentication-related operations are in one place.

Login
Register
Logout
Refresh
Restore
Clear
Role
Authentication status
Reactive State

Signals make it easy for Angular components to react to authentication changes.

Separation of Responsibilities

Components don't need to know how tokens are stored.

They simply interact with:

authService.login(...)
authService.logout(...)
authService.currentUser()
authService.isAuthenticated()
authService.getRole()
Easy to Extend

Additional functionality can be added later, such as:

getUserId()
getEmail()
getUsername()
hasRole()
hasAnyRole()
isTokenExpired()

  1. Important Security Considerations

This implementation is useful for learning and many applications, but authentication requires careful security considerations.


 http = inject(HttpClient);
  currentUserSignal = signal<authResponse | null>(null);
  public currentUser = this.currentUserSignal.asReadonly();

  login(data: loginDto) {
    return this.http.post<authResponse>(`${enviroment.apiUrl}/Auth/Login`, data);
  }

  logout() {
    return this.http.post(`${enviroment.apiUrl}/Auth/Logout`, {});
  }

  register(data: registerDto) {
    return this.http.post<authResponse>(`${enviroment.apiUrl}/Auth/Register`, data);
  }

  refreshToken() {
    const stored = localStorage.getItem('auth');

    if (!stored) {
      throw new Error(`auth doesn't exist`);
    }

    const tokens = JSON.parse(stored);

    return this.http.post(`${enviroment.apiUrl}/Auth/Refresh`, {
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
    });
  }

  restoreUser() {
    const stored = localStorage.getItem('auth');
    if (stored) {
      this.currentUserSignal.set(JSON.parse(stored));
    }
  }

  setUser(data: authResponse) {
    localStorage.setItem('auth', JSON.stringify(data));
    this.currentUserSignal.set(data);
  }

  clearUser() {
    localStorage.removeItem('auth');
    this.currentUserSignal.set(null);
  }

  isAuthenticated() {
    return this.currentUser() !== null;
  }

  getDecodedToken(): any | null {
    const user = this.currentUser();
    if (!user) return null;
    try {
      return jwtDecode(user.accessToken);
    } catch {
      return null;
    }
  }

  getRole(): string | null {
    const decoded = this.getDecodedToken();
    if (!decoded) return null;
    return decoded.role ?? null;
  }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)