Angular SDK Quickstart

Add Hawcx passwordless authentication to an Angular app

@hawcx/angular is a standalone-friendly wrapper around @hawcx/core. It gives you an Angular provider, an injectable HawcxService that exposes the flow as RxJS observables, route guards, an HTTP interceptor, and drop-in UI components.

Before You Begin

Configure your auth flow first

Before integrating the SDK, set up your project in the Hawcx Admin Console:

  1. Create a project, give it a name, and pick the environment it runs in.
  2. Configure authentication methods, starting with the primary MFA method (Email OTP, SMS, TOTP, and so on).
  3. Generate a Config ID. That value is what the SDK takes as configId.

You'll also copy the Base URL shown right next to the Config ID on the same Project Settings page. Pass it as apiBase if your project is not on the default https://api.hawcx.com/v1.

See Create a Project and Config IDs for detailed steps.

You also need:

  • Angular 17+ (@angular/common, @angular/core, @angular/forms, @angular/router) and rxjs 7. These are peer dependencies, so the SDK uses the versions already in your app. The signal inputs and @switch template blocks used below are Angular 17 features.
  • A backend endpoint you control that redeems the auth code. See Backend Exchange.

@hawcx/core is installed automatically as a dependency of @hawcx/angular.


How the flow works

  1. Call HawcxService.start(email), or drop in <hawcx-auth> and let it call start for you.
  2. The server drives the flow with steps (select method, enter code, and so on). Each one arrives on the state$ observable.
  3. When the flow completes, the SDK holds an authCode and the PKCE codeVerifier.
  4. Your backend exchanges those two values for verified claims and creates a session.

Steps 1 through 3 happen in the browser. Step 4 must happen on your server, because redeeming the code uses OAuth client credentials that must never ship to a browser.


Install

npm install @hawcx/angular

Configure

Add provideHawcx(...) to your application config. It registers HawcxService, the HAWCX_CONFIG token, and (unless you opt out) the HTTP interceptor.

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHawcx } from '@hawcx/angular';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHawcx({
      configId: 'YOUR_CONFIG_ID',
      tokenEndpoint: '/api/hawcx/exchange', // your backend endpoint that redeems the auth code
    }),
  ],
};

Never put OAuth client credentials in the browser. tokenEndpoint should point at your backend, which redeems the { code, code_verifier } using the Hawcx OAuth credentials issued for your project and returns your app's session or access token.

Protect routes

app.routes.ts
import { Routes } from '@angular/router';
import { authGuard, guestGuard } from '@hawcx/angular';

export const routes: Routes = [
  { path: 'dashboard', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent), canActivate: [authGuard()] },
  { path: 'login', loadComponent: () => import('./login.component').then(m => m.LoginComponent), canActivate: [guestGuard()] },
];

authGuard() redirects unauthenticated users (default /login); guestGuard() redirects already-signed-in users (default /). Both are factories, so call them, and both accept { redirectTo }. If you want the defaults with no call, use the ready-made isAuthenticated and isGuest constants instead.

Both guards read HawcxService.isAuthenticated$, which tracks the access token, not the flow state. A user only passes authGuard once the token exchange has run, whether that is via completeAuth(), exchangeToken(), <hawcx-auth>'s default autoExchangeToken, or your own setAccessToken(token) call.

Authenticated HTTP calls

provideHawcx installs the Hawcx HTTP interceptor, which attaches Authorization: Bearer <token> to outgoing HttpClient requests and signs out on a 401. Configure or disable it through the interceptor option:

app.config.ts
provideHawcx({
  configId: 'YOUR_CONFIG_ID',
  interceptor: { excludeUrls: [/^\/api\/public/, /^https:\/\/cdn\./] },
  // interceptor: false,  // register it yourself instead
});

A string entry in excludeUrls matches by substring (url.includes(pattern)), not by prefix or exact path, so it can also match unrelated URLs that happen to contain the same text. Prefer a RegExp like the one above when you need to match a specific path and its children.

hawcxInterceptor is a factory

hawcxInterceptor returns an HttpInterceptorFn; it is not one itself. Register it as hawcxInterceptor() or hawcxInterceptor({ ... }), never bare. defaultHawcxInterceptor is the ready-made instance if you want one without calling anything.

To manage HttpClient yourself, swap provideHawcx for provideHawcxService and add the interceptor by hand:

app.config.ts
providers: [
  provideHawcxService({ configId: 'YOUR_CONFIG_ID' }),
  provideHttpClient(withInterceptors([myInterceptor, hawcxInterceptor()])),
]

See the interceptor reference for every option.


Render Flow States

The SDK is a state machine. HawcxService.state$ emits AuthState, a union discriminated on status:

state.statusWhat to Show
idleEmail input form
loadingSpinner
stepStep-specific UI, read from state.step
completedSuccess message, hand the code to your backend
errorError message + retry button

Step Types (state.step.type)

When state.status === 'step', render from state.step.type:

StepUser InputMethod to Call
select_methodPick auth methodselectMethod(methodId)
enter_codeEnter OTP codesubmitCode(code)
enter_totpEnter authenticator codesubmitTotp(code)
setup_totpScan QR + enter codesubmitTotp(code)
setup_smsEnter phone numbersubmitPhone(phone)
await_approvalWait (show QR if step.qrData is set)SDK polls automatically
redirectN/ASend the browser to step.url

Angular's @switch narrows the union, so state.step and state.error are typed inside their own branches:

login.component.ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HawcxService } from '@hawcx/angular';

@Component({
  standalone: true,
  selector: 'app-login',
  imports: [AsyncPipe],
  template: `
    @if (hawcx.state$ | async; as state) {
      @switch (state.status) {
        @case ('idle') {
          <button (click)="hawcx.start('[email protected]')">Sign in</button>
        }
        @case ('loading') {
          <p>Loading...</p>
        }
        @case ('step') {
          <p>Step: {{ state.step.type }}</p>
        }
        @case ('completed') {
          <p>Signed in.</p>
        }
        @case ('error') {
          <p>{{ state.error.message }}</p>
          <button (click)="hawcx.reset()">Try again</button>
        }
      }
    }
  `,
})
export class LoginComponent {
  readonly hawcx = inject(HawcxService);
}

If you only need the step name, stepType$ gives it directly as Observable<string | null>, emitting null whenever the flow is not on a step. isLoading$ and notice$ are similar projections of state$ for a spinner and for non-fatal server notices.


Minimal Flow Example

The fast path in Angular is <hawcx-auth>, a standalone component that renders the whole flow (identifier, method selection, OTP / TOTP / SMS / QR approval) and emits the result.

login.component.ts
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { HawcxAuthComponent } from '@hawcx/angular';
import type { AuthResult, AuthError } from '@hawcx/angular';

@Component({
  standalone: true,
  selector: 'app-login',
  imports: [HawcxAuthComponent],
  template: `
    <hawcx-auth
      flowType="signin"
      [identifier]="email"
      (authSuccess)="onSuccess($event)"
      (authError)="onError($event)"
      (cancelled)="onCancelled()">
    </hawcx-auth>
  `,
})
export class LoginComponent {
  private readonly router = inject(Router);
  email = '';

  // This is Angular's own AuthResult, not core's. See Backend Exchange below.
  onSuccess(result: AuthResult) {
    if (result.tokens) {
      // tokenEndpoint was configured and autoExchangeToken was left on,
      // so your backend has already returned tokens.access_token.
      this.router.navigateByUrl('/dashboard');
      return;
    }
    // Otherwise redeem result.authCode + result.codeVerifier yourself.
  }

  // Only fatal errors reach this output; retryable ones are handled in-component.
  onError(err: AuthError) {
    console.error(err.code, err.message);
  }

  onCancelled() {
    this.email = '';
  }
}
InputTypeDefaultPurpose
flowTypeAuthModerequiredsignin, signup, or account_manage
identifierstringrequiredThe user's email
autoStartbooleantrueStart as soon as both required inputs are set
autoExchangeTokenbooleantrueRun the token exchange on completion
startTokenstringnoneStep-up start token from your backend
showCancelButtonbooleantrueRender the cancel button

Prefer to build your own UI? Inject HawcxService and drive start(), selectMethod(), submitCode(), submitTotp(), submitPhone(), resend() and cancel() yourself, rendering from state$ as shown above.


Backend Exchange

When the flow completes, the SDK holds an auth code and a PKCE code verifier. Redeeming them requires OAuth client credentials, so the exchange runs on your server. The endpoint is yours to implement; Hawcx provides the SDK only.

With tokenEndpoint set, HawcxService does the browser half for you:

// Throws if the flow has not completed.
const result = await this.hawcx.completeAuth();

if (result.tokens) {
  // completeAuth() POSTed { code, code_verifier } to your tokenEndpoint,
  // and result.tokens is the TokenResponse your backend returned.
  // The access token is now stored and attached to outgoing requests.
}

completeAuth() returns Angular's AuthResult. Call exchangeToken() instead when you only want the raw TokenResponse.

Two AuthResult types

completeAuth() and the <hawcx-auth> authSuccess output emit Angular's own AuthResult: { authCode, codeVerifier?, tokens?, stepUpReceipt? }. The completion$ observable and getCompletion() emit CoreAuthResult, which is @hawcx/core's result type: { authCode, expiresAt, codeVerifier?, sessionToken? }. The names sound alike but the shapes differ: there is no tokens on CoreAuthResult and no expiresAt on Angular's AuthResult. See AuthResult (Angular).

Without a tokenEndpoint, read the codes off completion$ and post them yourself. Use a different path from tokenEndpoint: the SDK's own exchange POSTs { code, code_verifier }, while this hand-rolled request sends { authCode, codeVerifier }, and one backend route cannot serve both payload shapes.

exchange.component.ts
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { HawcxService } from '@hawcx/angular';
import { filter, take } from 'rxjs/operators';

@Component({
  standalone: true,
  selector: 'app-exchange',
  template: `<p>Finishing sign-in...</p>`,
})
export class ExchangeComponent {
  private readonly hawcx = inject(HawcxService);
  private readonly http = inject(HttpClient);
  private readonly router = inject(Router);

  constructor() {
    // take(1) completes the stream, so this subscription needs no teardown.
    this.hawcx.completion$
      .pipe(filter(Boolean), take(1))
      .subscribe((completion) => {
        // completion is CoreAuthResult
        this.http
          .post('/api/hawcx/exchange-manual', {
            authCode: completion.authCode,
            codeVerifier: completion.codeVerifier,
          })
          .subscribe(() => this.router.navigateByUrl('/'));
      });
  }
}

The operators come from rxjs/operators so the import resolves on every 7.x, including the ^7.0.0 floor the SDK declares. RxJS re-exports them from the rxjs root only from 7.2.0 onward.

For the server side of that endpoint, see the Node.js backend quickstart, which trades the pair for verified claims.


Error Handling

error$ emits the current AuthError or null. Each error carries a category that tells you how to recover:

login-errors.component.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthErrorCategory, HawcxService } from '@hawcx/angular';

@Component({
  standalone: true,
  selector: 'app-login-errors',
  template: `<p>{{ message }}</p>`,
})
export class LoginErrorsComponent {
  private readonly hawcx = inject(HawcxService);
  message = '';

  constructor() {
    // error$ never completes, so it needs teardown. takeUntilDestroyed() has to
    // run in an injection context, and a field initialiser or constructor is one.
    this.hawcx.error$.pipe(takeUntilDestroyed()).subscribe((error) => {
      if (!error) {
        this.message = '';
        return;
      }

      switch (error.category) {
        case AuthErrorCategory.RETRYABLE:
          // Transient. Retry the same action.
          this.message = 'Something went wrong. Try that again.';
          break;
        case AuthErrorCategory.USER_ACTION:
          // The user has to fix something (wrong code, bad phone number).
          this.message = error.message;
          break;
        case AuthErrorCategory.FATAL:
          // Unrecoverable. Start over.
          this.hawcx.reset();
          this.message = 'Sign-in failed. Please start again.';
          break;
      }
    });
  }
}

takeUntilDestroyed() comes from @angular/core/rxjs-interop (Angular 16+), so it ships with the @angular/core you already have. Rendering hawcx.error$ | async in a template works too, since the async pipe unsubscribes on destroy.

AuthErrorCategory is an enum, so compare against its members rather than against the raw 'retryable' / 'user_action' / 'fatal' strings. The same error is also on state$ whenever state.status === 'error', which is what the @switch example above renders.


Next steps

Your app can now authenticate. The flow returns an auth code that your backend still has to exchange, so an Angular integration is not complete until that half is wired up.

The @hawcx/core state machine underneath all of this (every AuthState variant, the step payloads, and server notices) is documented in the Web SDK API Reference. Every type this page names is re-exported by @hawcx/angular, so you can import AuthState, AuthStep, AuthMode, AuthError, AuthErrorCategory, Method and CoreAuthResult straight from it. A few core types are not on that entry point, AuthNotice and StepType among them; import those from @hawcx/core instead.

Last updated on