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.
- 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.
- Injecting HttpClient
The first dependency is Angular's HttpClient.
http = inject(HttpClient);
- 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.
- 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.
- 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);
}
});
- Why Return the Observable?
A common mistake is subscribing inside the service:
login(data: loginDto) {
this.http.post(...).subscribe(response => {
// ...
});
}
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.
- Storing Authentication Information
After successful login, I use:
setUser(data: authResponse) {
localStorage.setItem(
'auth',
JSON.stringify(data)
);
this.currentUserSignal.set(data);
}
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
- 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
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.
- 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)
);
}
}
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.
- 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.
- 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.
- 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
}
);
}
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.
- 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.
- 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;
}
}
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.
- 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;
}
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.
- 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.
- 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;
}
}
- 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()
/ \
/ \
▼ ▼
localStorage Signal
│ │
│ ▼
│ Components
│
▼
Browser refresh
│
▼
restoreUser()
│
▼
Signal
This is a simple but effective pattern for managing authentication state in Angular.
- 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()
- 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;
}
Top comments (0)