Week 23: Security, Authentication & Authorization

Every app you've built has had a fake or absent login. This week makes it real — safely. Getting authentication wrong doesn't just mean a bug; it means real user accounts and real data at risk.

Phase 6 of 7 Week 23 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Implement authentication/authorization patterns safely
  • Explain the real tradeoffs between token storage strategies
  • Build role-based UI that hides options a user can't act on

1. XSS, CSRF & Angular's Built-in Sanitization

XSS (Cross-Site Scripting) happens when attacker-controlled content gets executed as code in another user's browser. Angular sanitizes values bound into your templates by default — you have to deliberately opt out to create a vulnerability:

safe by default
<!-- Angular escapes this automatically -- rendered as literal text, not executed -->
<div>{{ userComment }}</div>

<!-- innerHTML binding is ALSO sanitized by default -- script tags are stripped -->
<div [innerHTML]="userSuppliedHtml"></div>
deliberately unsafe — avoid unless you truly trust the source
import { DomSanitizer } from '@angular/platform-browser';

constructor(private sanitizer: DomSanitizer) {}

// bypassSecurityTrustHtml explicitly disables sanitization for this value.
// Only ever call this on content YOU generated or fully trust -- never on raw user input.
trustedHtml = this.sanitizer.bypassSecurityTrustHtml(userSuppliedHtml);

CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into making an unwanted request to your app using their existing session — the standard defense is a CSRF token the server issues and requires back on state-changing requests, which an attacker's page can't obtain.

2. Token-Based Auth (JWT) & Refresh-Token Flows

A JWT (JSON Web Token) is a signed, self-contained token — the server can verify it wasn't tampered with, without a database lookup. A short-lived access token paired with a longer-lived refresh token is the standard pattern: the access token authenticates requests but expires quickly (minimizing damage if it leaks), and the refresh token gets a new access token without forcing the user to log in again.

refresh-token interceptor — building on Week 14
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';

export const refreshTokenInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);

  return next(req).pipe(
    catchError((err) => {
      if (err instanceof HttpErrorResponse && err.status === 401) {
        return auth.refreshAccessToken().pipe(
          switchMap((newToken) => {
            const retried = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
            return next(retried);
          })
        );
      }
      return throwError(() => err);
    })
  );
};

This extends the auth interceptor from Week 14 with a real recovery path: a 401 triggers a silent refresh, then retries the original request with the new token — the user never sees a failed request or an unexpected logout, as long as the refresh token itself is still valid.

3. OAuth2/OIDC Login Flows in an SPA

For "Log in with Google/GitHub"-style authentication, OAuth2 (authorization) and OIDC (identity, built on top of OAuth2) are the standard protocols. For a single-page app, the Authorization Code flow with PKCE is the current best practice — the older "implicit flow" is now discouraged because it exposed tokens directly in the URL.

conceptual flow
// 1. App redirects to the identity provider with a PKCE "code_challenge"
// 2. User logs in and consents, on the IDENTITY PROVIDER's own page (not your app's)
// 3. Provider redirects back with a short-lived authorization CODE (not a token yet)
// 4. Your app exchanges that code -- plus the original PKCE "code_verifier" -- for tokens
// 5. PKCE proves the token exchange is coming from the same app that started the flow,
//    preventing a malicious app from intercepting and using the authorization code

In practice, you'd use a well-tested library (like angular-oauth2-oidc) rather than hand-rolling this flow — the protocol has enough subtle security requirements that a battle-tested implementation is worth the dependency, similar to how Week 8 recommended the CDK over hand-rolled overlay positioning.

4. Role- and Permission-Based UI

Hiding a button a user can't act on is good UX — but it's never the actual security boundary. The server must independently enforce every permission check; client-side hiding is purely about not showing options that would fail anyway.

has-role.directive.ts
import { Directive, input, inject, TemplateRef, ViewContainerRef, effect } from '@angular/core';
import { AuthService } from './auth.service';

@Directive({ selector: '[appHasRole]' })
export class HasRole {
  private templateRef = inject(TemplateRef);
  private viewContainerRef = inject(ViewContainerRef);
  private auth = inject(AuthService);

  requiredRole = input.required<string>({ alias: 'appHasRole' });

  constructor() {
    effect(() => {
      this.viewContainerRef.clear();
      if (this.auth.currentUser()?.roles.includes(this.requiredRole())) {
        this.viewContainerRef.createEmbeddedView(this.templateRef);
      }
    });
  }
}
using it — project-detail.html
<button *appHasRole="'admin'" (click)="deleteProject()">Delete Project</button>

This is another structural directive (Week 8's *appUnless pattern) — but the critical thing to remember is what it doesn't do: the server's DELETE /api/projects/:id endpoint still needs its own independent role check, because a user could always call that endpoint directly, bypassing your UI entirely.

5. Secure Storage of Tokens

Where you store an access token has real security implications:

  • localStorage — simple, but readable by any JavaScript running on the page, including injected via a successful XSS attack. A real risk if your app is ever vulnerable to XSS at all.
  • An httpOnly cookie — inaccessible to JavaScript entirely, immune to token theft via XSS. Requires CSRF protection instead (Section 1), and needs the server's cooperation to set it that way.
  • In-memory (a plain variable) — safest against both XSS token theft and CSRF, but lost on page refresh, requiring a refresh-token flow (via an httpOnly cookie) to silently re-establish it.

A common, defensible pattern: refresh token in an httpOnly cookie, access token held in memory only — you get the XSS resistance of cookie storage for the long-lived credential, and the refresh flow from Section 2 handles re-establishing the short-lived access token after every page load.

6. Content Security Policy Basics for an Angular App

A CSP is a response header telling the browser exactly which sources of scripts, styles, and other resources are allowed to load — a second layer of defense that limits the damage even if an XSS vulnerability somehow exists.

example CSP header
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' fonts.googleapis.com;
  font-src fonts.gstatic.com;
  connect-src 'self' api.yourapp.com;

script-src 'self' means only scripts from your own origin can execute — an injected <script src="evil.com/steal.js"> would be blocked by the browser itself, even if it somehow made it into the DOM. This is set at the server or hosting-platform level, not in Angular code, but it's an Angular app's responsibility to actually work correctly under a strict policy — for instance, avoiding inline styles that a strict CSP would block.

7. Hands-on Exercise

Hands-on

Add a full login flow with refresh tokens and role-based guards

Wire real (or realistically mocked) authentication into your dashboard app.

Requirements:

  1. A login form (Week 12's reactive-forms patterns) that authenticates against a real or mocked endpoint, storing the access token in memory and the refresh token in a cookie (or a mocked equivalent if your backend is fully simulated).
  2. A refreshTokenInterceptor that silently refreshes on a 401 and retries the original request, per Section 2.
  3. Extend the Week 11 authGuard to check for a specific role on at least one route, redirecting to a "not authorized" page (not the login page) if the user is authenticated but lacks permission.
  4. A *appHasRole structural directive hiding at least one UI element for non-admin users — and confirm, by calling the underlying action directly (e.g. via the browser console), that the server/mock still rejects it independently.
  5. Write a one-paragraph justification for your token storage choice, referencing Section 5's tradeoffs.
Hint

Step 4 is the one worth taking seriously — if hiding a button is the only thing preventing an unauthorized action, you have a real vulnerability, not just a UX gap. Confirming the server independently rejects it is the actual point of the exercise.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does calling bypassSecurityTrustHtml() on raw, unmodified user input create a real XSS vulnerability?

It explicitly disables Angular's automatic sanitization for that value, telling Angular "trust this completely." If the value came from user input without any sanitization of your own, an attacker can submit a <script> tag (or an event handler attribute) as their "comment" or "bio," and it will execute in every other user's browser exactly as written.

Q2

Why pair a short-lived access token with a longer-lived refresh token, instead of just issuing one long-lived token?

It limits the damage if the access token is ever stolen — it expires quickly, so a leaked token is only useful for a short window. The refresh token lets the user stay logged in without re-entering credentials, but it's used far less often (only to obtain new access tokens) and can be stored more securely, reducing its own exposure.

Q3

A developer hides the "Delete" button for non-admins using *appHasRole and considers the feature secure. What's missing?

Server-side enforcement of the same permission check. Hiding UI is purely cosmetic — a non-admin user could still call the delete endpoint directly (via the browser console, a tool like curl, or a modified request) if the server doesn't independently verify the user's role before performing the deletion. Client-side role checks are a UX improvement, never a security boundary on their own.

Q4

Why is an httpOnly cookie more resistant to token theft via XSS than localStorage?

httpOnly cookies are, by design, inaccessible to any JavaScript running on the page — including malicious injected scripts from a successful XSS attack. localStorage has no such restriction: any script that runs on your page, whether yours or an attacker's injected one, can read it directly, which is exactly what an XSS attack would exploit to steal a token.