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:
- Create a project, give it a name, and pick the environment it runs in.
- Configure authentication methods, starting with the primary MFA method (Email OTP, SMS, TOTP, and so on).
- 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) andrxjs7. These are peer dependencies, so the SDK uses the versions already in your app. The signal inputs and@switchtemplate 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
- Call
HawcxService.start(email), or drop in<hawcx-auth>and let it callstartfor you. - The server drives the flow with steps (select method, enter code, and so on). Each one arrives
on the
state$observable. - When the flow completes, the SDK holds an
authCodeand the PKCEcodeVerifier. - 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/angularConfigure
Add provideHawcx(...) to your application config. It registers HawcxService, the
HAWCX_CONFIG token, and (unless you opt out) the HTTP interceptor.
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
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:
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:
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.status | What to Show |
|---|---|
idle | Email input form |
loading | Spinner |
step | Step-specific UI, read from state.step |
completed | Success message, hand the code to your backend |
error | Error message + retry button |
Step Types (state.step.type)
When state.status === 'step', render from state.step.type:
| Step | User Input | Method to Call |
|---|---|---|
select_method | Pick auth method | selectMethod(methodId) |
enter_code | Enter OTP code | submitCode(code) |
enter_totp | Enter authenticator code | submitTotp(code) |
setup_totp | Scan QR + enter code | submitTotp(code) |
setup_sms | Enter phone number | submitPhone(phone) |
await_approval | Wait (show QR if step.qrData is set) | SDK polls automatically |
redirect | N/A | Send the browser to step.url |
Angular's @switch narrows the union, so state.step and state.error are typed inside their
own branches:
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.
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 = '';
}
}| Input | Type | Default | Purpose |
|---|---|---|---|
flowType | AuthMode | required | signin, signup, or account_manage |
identifier | string | required | The user's email |
autoStart | boolean | true | Start as soon as both required inputs are set |
autoExchangeToken | boolean | true | Run the token exchange on completion |
startToken | string | none | Step-up start token from your backend |
showCancelButton | boolean | true | Render 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.
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:
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.
Exchange the code on your backend (Node.js)
Trade the auth code for verified user claims.
Exchange the code on your backend (Python)
Trade the auth code for verified user claims.
Exchange the code on your backend (Java)
Trade the auth code for verified user claims.
Angular API Reference
Providers, HawcxService, guards, the interceptor, the components, and both AuthResult
types in full.
Troubleshooting
Common failures during a first integration and how to read them.
Get support
Slack and email, and what to have ready before you ask.
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