diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index fd32c70..204b8ea 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.12.0] - 2026-05-20
+
+### Added
+- Security module: `provideSecurity(config?)` factory with `SECURITY_CONFIG` injection token
+- `SecurityService` — signal-based CSRF config management, cookie/manual token read, SSR-safe
+- `csrfInterceptor` — functional HTTP interceptor, adds CSRF header on mutation requests (POST/PUT/PATCH/DELETE)
+- PII masking utilities: `maskNif()`, `maskCard()`, `maskPhone()`, `maskEmail()`, `maskIban()`
+- `FfPiiMaskPipe` — standalone pipe for template-based PII masking (`{{ value | ffPiiMask:'nif' }}`)
+- `FfPiiMaskDirective` — attribute directive with toggle show/hide and event emission
+- HTML sanitization utilities: `sanitizeHtml()`, `escapeXss()`, `stripTags()`
+- Types: `PiiFieldType`, `PiiMaskingMode`, `CsrfConfig`, `SecurityConfig`
+
## [0.11.0] - 2026-05-20
### Changed
diff --git a/packages/core/package.json b/packages/core/package.json
index fd0dcdd..d541bdd 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
- "version": "0.11.0",
+ "version": "0.12.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index bfc9064..8eb002b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -12,3 +12,4 @@ export * from './lib/error-handling';
export * from './lib/feature-flags';
export * from './lib/storage';
export * from './lib/environment';
+export * from './lib/security';
diff --git a/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts b/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts
new file mode 100644
index 0000000..35747ad
--- /dev/null
+++ b/packages/core/src/lib/security/csrf/csrf.interceptor.spec.ts
@@ -0,0 +1,143 @@
+import { TestBed } from '@angular/core/testing';
+import {
+ HttpClient,
+ provideHttpClient,
+ withInterceptors,
+ withNoXsrfProtection,
+} from '@angular/common/http';
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from '@angular/common/http/testing';
+import { DOCUMENT } from '@angular/common';
+import { csrfInterceptor } from './csrf.interceptor';
+import { SecurityService, SECURITY_CONFIG } from '../security.service';
+
+describe('csrfInterceptor', () => {
+ let http: HttpClient;
+ let httpMock: HttpTestingController;
+ let security: SecurityService;
+
+ function setup(config?: object, cookieStr = '') {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ SecurityService,
+ { provide: DOCUMENT, useValue: { cookie: cookieStr } },
+ ...(config ? [{ provide: SECURITY_CONFIG, useValue: config }] : []),
+ provideHttpClient(
+ withInterceptors([csrfInterceptor]),
+ withNoXsrfProtection(),
+ ),
+ provideHttpClientTesting(),
+ ],
+ });
+ http = TestBed.inject(HttpClient);
+ httpMock = TestBed.inject(HttpTestingController);
+ security = TestBed.inject(SecurityService);
+ }
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ // -----------------------------------------------------------------
+ // Adds header on mutations
+ // -----------------------------------------------------------------
+ it('should add CSRF header on POST', () => {
+ setup(undefined, 'XSRF-TOKEN=tok123');
+ http.post('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123');
+ req.flush({});
+ });
+
+ it('should add CSRF header on PUT', () => {
+ setup(undefined, 'XSRF-TOKEN=tok123');
+ http.put('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123');
+ req.flush({});
+ });
+
+ it('should add CSRF header on PATCH', () => {
+ setup(undefined, 'XSRF-TOKEN=tok123');
+ http.patch('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123');
+ req.flush({});
+ });
+
+ it('should add CSRF header on DELETE', () => {
+ setup(undefined, 'XSRF-TOKEN=tok123');
+ http.delete('/api/data').subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('tok123');
+ req.flush({});
+ });
+
+ // -----------------------------------------------------------------
+ // Skips GET
+ // -----------------------------------------------------------------
+ it('should NOT add header on GET', () => {
+ setup(undefined, 'XSRF-TOKEN=tok123');
+ http.get('/api/data').subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false);
+ req.flush({});
+ });
+
+ // -----------------------------------------------------------------
+ // Pass-through when disabled
+ // -----------------------------------------------------------------
+ it('should pass-through when CSRF is disabled', () => {
+ setup({ csrf: { enabled: false } }, 'XSRF-TOKEN=tok123');
+ http.post('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false);
+ req.flush({});
+ });
+
+ // -----------------------------------------------------------------
+ // Pass-through when no token
+ // -----------------------------------------------------------------
+ it('should pass-through when no token available', () => {
+ setup(undefined, '');
+ http.post('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.has('X-XSRF-TOKEN')).toBe(false);
+ req.flush({});
+ });
+
+ // -----------------------------------------------------------------
+ // Custom header name
+ // -----------------------------------------------------------------
+ it('should use custom header name from config', () => {
+ setup({ csrf: { headerName: 'X-MY-CSRF' } }, 'XSRF-TOKEN=tok123');
+ http.post('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-MY-CSRF')).toBe('tok123');
+ req.flush({});
+ });
+
+ // -----------------------------------------------------------------
+ // Manual token
+ // -----------------------------------------------------------------
+ it('should use manually set token over cookie', () => {
+ setup(undefined, 'XSRF-TOKEN=cookie-tok');
+ security.setCsrfToken('manual-tok');
+ http.post('/api/data', {}).subscribe();
+
+ const req = httpMock.expectOne('/api/data');
+ expect(req.request.headers.get('X-XSRF-TOKEN')).toBe('manual-tok');
+ req.flush({});
+ });
+});
diff --git a/packages/core/src/lib/security/csrf/csrf.interceptor.ts b/packages/core/src/lib/security/csrf/csrf.interceptor.ts
new file mode 100644
index 0000000..07fc552
--- /dev/null
+++ b/packages/core/src/lib/security/csrf/csrf.interceptor.ts
@@ -0,0 +1,43 @@
+import { inject } from '@angular/core';
+import { HttpInterceptorFn } from '@angular/common/http';
+import { SecurityService } from '../security.service';
+
+/** HTTP methods that mutate state and require CSRF protection. */
+const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+
+/**
+ * Functional HTTP interceptor that attaches a CSRF token header
+ * on mutation requests (POST, PUT, PATCH, DELETE).
+ *
+ * Reads the token and header name from `SecurityService`.
+ * Pass-through if CSRF is disabled in config or no token is available.
+ *
+ * Registration:
+ * ```typescript
+ * provideHttpClient(withInterceptors([csrfInterceptor]))
+ * ```
+ */
+export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
+ const security = inject(SecurityService);
+
+ if (!MUTATION_METHODS.has(req.method)) {
+ return next(req);
+ }
+
+ const csrfConfig = security.config().csrf;
+ if (csrfConfig?.enabled === false) {
+ return next(req);
+ }
+
+ const token = security.getCsrfToken();
+ if (!token) {
+ return next(req);
+ }
+
+ const headerName = csrfConfig?.headerName ?? 'X-XSRF-TOKEN';
+ const cloned = req.clone({
+ setHeaders: { [headerName]: token },
+ });
+
+ return next(cloned);
+};
diff --git a/packages/core/src/lib/security/index.ts b/packages/core/src/lib/security/index.ts
new file mode 100644
index 0000000..646395c
--- /dev/null
+++ b/packages/core/src/lib/security/index.ts
@@ -0,0 +1,19 @@
+// Types
+export type { PiiFieldType, PiiMaskingMode, CsrfConfig, SecurityConfig } from './security.types';
+
+// Provider factory
+export { provideSecurity } from './provide-security';
+
+// Service + token
+export { SecurityService, SECURITY_CONFIG } from './security.service';
+
+// CSRF interceptor
+export { csrfInterceptor } from './csrf/csrf.interceptor';
+
+// PII masking
+export { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii/pii.utils';
+export { FfPiiMaskPipe } from './pii/pii-mask.pipe';
+export { FfPiiMaskDirective } from './pii/pii-mask.directive';
+
+// Sanitization
+export { sanitizeHtml, escapeXss, stripTags } from './sanitization/sanitize.utils';
diff --git a/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts b/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts
new file mode 100644
index 0000000..21ec65d
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii-mask.directive.spec.ts
@@ -0,0 +1,105 @@
+import { Component, ViewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FfPiiMaskDirective } from './pii-mask.directive';
+
+@Component({
+ template: `
+
+
+ `,
+ standalone: true,
+ imports: [FfPiiMaskDirective],
+})
+class TestHostComponent {
+ @ViewChild(FfPiiMaskDirective) directive!: FfPiiMaskDirective;
+ type: 'nif' | 'card' | 'phone' | 'email' | 'iban' = 'nif';
+ value = '12345678A';
+ lastEvent: 'visible' | 'hidden' | null = null;
+}
+
+describe('FfPiiMaskDirective', () => {
+ let fixture: ComponentFixture;
+ let host: TestHostComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ });
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ });
+
+ it('should show masked value initially', () => {
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('******78A');
+ });
+
+ it('should reveal real value after toggle', () => {
+ fixture.detectChanges();
+ host.directive.toggle();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('12345678A');
+ });
+
+ it('should re-mask after second toggle', () => {
+ fixture.detectChanges();
+ host.directive.toggle(); // reveal
+ host.directive.toggle(); // mask again
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('******78A');
+ });
+
+ it('should emit "visible" when revealing', () => {
+ fixture.detectChanges();
+ host.directive.toggle();
+ expect(host.lastEvent).toBe('visible');
+ });
+
+ it('should emit "hidden" when re-masking', () => {
+ fixture.detectChanges();
+ host.directive.toggle(); // visible
+ host.directive.toggle(); // hidden
+ expect(host.lastEvent).toBe('hidden');
+ });
+
+ it('should report isMasked correctly', () => {
+ fixture.detectChanges();
+ expect(host.directive.isMasked).toBe(true);
+ host.directive.toggle();
+ expect(host.directive.isMasked).toBe(false);
+ });
+
+ it('should render masked value for a different NIF', () => {
+ host.value = 'X1234567L';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('******67L');
+ });
+
+ it('should render masked value for an IBAN', () => {
+ host.type = 'iban';
+ host.value = 'ES9121000418450200051332';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('ES91 **** **** **** **** 1332');
+ });
+
+ it('should update when type changes', () => {
+ host.type = 'email';
+ host.value = 'user@mail.com';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('u***@mail.com');
+ });
+
+ it('should mask a card number', () => {
+ host.type = 'card';
+ host.value = '4111111111111111';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('**** **** **** 1111');
+ });
+
+ it('should handle empty value gracefully', () => {
+ host.value = '';
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('span').textContent).toBe('');
+ });
+});
diff --git a/packages/core/src/lib/security/pii/pii-mask.directive.ts b/packages/core/src/lib/security/pii/pii-mask.directive.ts
new file mode 100644
index 0000000..1e7f905
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii-mask.directive.ts
@@ -0,0 +1,74 @@
+import {
+ Directive,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnChanges,
+ Output,
+ Renderer2,
+ inject,
+} from '@angular/core';
+
+import { PiiFieldType } from '../security.types';
+import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils';
+
+const MASK_FN: Record string> = {
+ nif: maskNif,
+ card: maskCard,
+ phone: maskPhone,
+ email: maskEmail,
+ iban: maskIban,
+};
+
+/**
+ * Attribute directive that displays PII data in masked form
+ * and allows toggling to reveal the real value.
+ *
+ * @example
+ * ```html
+ *
+ *
+ * ```
+ */
+@Directive({ selector: '[ffPiiMask]', standalone: true })
+export class FfPiiMaskDirective implements OnChanges {
+ private readonly el = inject(ElementRef);
+ private readonly renderer = inject(Renderer2);
+
+ /** The PII field type used to select the masking function. */
+ @Input({ required: true }) ffPiiMask!: PiiFieldType;
+
+ /** The raw (unmasked) value. */
+ @Input({ required: true }) ffPiiMaskValue!: string;
+
+ /** Emits `'visible'` or `'hidden'` when the masked state changes. */
+ @Output() ffPiiMaskToggled = new EventEmitter<'visible' | 'hidden'>();
+
+ private masked = true;
+
+ ngOnChanges(): void {
+ this.render();
+ }
+
+ /** Toggles between masked and unmasked display. */
+ toggle(): void {
+ this.masked = !this.masked;
+ this.render();
+ this.ffPiiMaskToggled.emit(this.masked ? 'hidden' : 'visible');
+ }
+
+ /** Whether the value is currently masked. */
+ get isMasked(): boolean {
+ return this.masked;
+ }
+
+ private render(): void {
+ const text = this.masked
+ ? MASK_FN[this.ffPiiMask](this.ffPiiMaskValue ?? '')
+ : (this.ffPiiMaskValue ?? '');
+ this.renderer.setProperty(this.el.nativeElement, 'textContent', text);
+ }
+}
diff --git a/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts b/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts
new file mode 100644
index 0000000..0526d96
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii-mask.pipe.spec.ts
@@ -0,0 +1,59 @@
+import { FfPiiMaskPipe } from './pii-mask.pipe';
+
+describe('FfPiiMaskPipe', () => {
+ let pipe: FfPiiMaskPipe;
+
+ beforeEach(() => {
+ pipe = new FfPiiMaskPipe();
+ });
+
+ // ---------------------------------------------------------------------
+ // NIF masking
+ // ---------------------------------------------------------------------
+ it('should mask a NIF', () => {
+ expect(pipe.transform('12345678A', 'nif')).toBe('******78A');
+ });
+
+ // ---------------------------------------------------------------------
+ // Card masking
+ // ---------------------------------------------------------------------
+ it('should mask a card number', () => {
+ expect(pipe.transform('4111111111111111', 'card')).toBe('**** **** **** 1111');
+ });
+
+ // ---------------------------------------------------------------------
+ // Phone masking
+ // ---------------------------------------------------------------------
+ it('should mask a phone number', () => {
+ expect(pipe.transform('612345678', 'phone')).toBe('*** *** 678');
+ });
+
+ // ---------------------------------------------------------------------
+ // Email masking
+ // ---------------------------------------------------------------------
+ it('should mask an email', () => {
+ expect(pipe.transform('user@mail.com', 'email')).toBe('u***@mail.com');
+ });
+
+ // ---------------------------------------------------------------------
+ // IBAN masking
+ // ---------------------------------------------------------------------
+ it('should mask an IBAN', () => {
+ expect(pipe.transform('ES9121000418450200051332', 'iban')).toBe('ES91 **** **** **** **** 1332');
+ });
+
+ // ---------------------------------------------------------------------
+ // Null / undefined / empty handling
+ // ---------------------------------------------------------------------
+ it('should return empty string for null', () => {
+ expect(pipe.transform(null, 'nif')).toBe('');
+ });
+
+ it('should return empty string for undefined', () => {
+ expect(pipe.transform(undefined, 'card')).toBe('');
+ });
+
+ it('should return empty string for empty string', () => {
+ expect(pipe.transform('', 'email')).toBe('');
+ });
+});
diff --git a/packages/core/src/lib/security/pii/pii-mask.pipe.ts b/packages/core/src/lib/security/pii/pii-mask.pipe.ts
new file mode 100644
index 0000000..2707d00
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii-mask.pipe.ts
@@ -0,0 +1,30 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { PiiFieldType } from '../security.types';
+import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils';
+
+const MASK_FN: Record string> = {
+ nif: maskNif,
+ card: maskCard,
+ phone: maskPhone,
+ email: maskEmail,
+ iban: maskIban,
+};
+
+/**
+ * Masks a PII value according to its field type.
+ *
+ * @example
+ * ```html
+ * {{ nifValue | ffPiiMask:'nif' }}
+ * {{ cardNumber | ffPiiMask:'card' }}
+ * {{ userEmail | ffPiiMask:'email' }}
+ * ```
+ */
+@Pipe({ name: 'ffPiiMask', standalone: true })
+export class FfPiiMaskPipe implements PipeTransform {
+ transform(value: string | null | undefined, type: PiiFieldType): string {
+ if (!value) return '';
+ return MASK_FN[type](value);
+ }
+}
diff --git a/packages/core/src/lib/security/pii/pii.utils.spec.ts b/packages/core/src/lib/security/pii/pii.utils.spec.ts
new file mode 100644
index 0000000..90767d1
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii.utils.spec.ts
@@ -0,0 +1,189 @@
+import { maskNif, maskCard, maskPhone, maskEmail, maskIban } from './pii.utils';
+
+describe('PII masking utils', () => {
+ // -----------------------------------------------------------------------
+ // maskNif
+ // -----------------------------------------------------------------------
+ describe('maskNif', () => {
+ it('should mask a standard NIF keeping last 3 chars', () => {
+ // '12345678A' → 9 chars, 9-3=6 asterisks + '78A'
+ expect(maskNif('12345678A')).toBe('******78A');
+ });
+
+ it('should mask a NIE keeping last 3 chars', () => {
+ // 'X1234567L' → 9 chars, 9-3=6 asterisks + '67L'
+ expect(maskNif('X1234567L')).toBe('******67L');
+ });
+
+ it('should mask a CIF', () => {
+ expect(maskNif('B12345678')).toBe('******678');
+ });
+
+ it('should return original if less than 4 chars', () => {
+ expect(maskNif('AB')).toBe('AB');
+ });
+
+ it('should return empty string for empty input', () => {
+ expect(maskNif('')).toBe('');
+ });
+
+ it('should handle null-like values gracefully', () => {
+ expect(maskNif(null as unknown as string)).toBe('');
+ expect(maskNif(undefined as unknown as string)).toBe('');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // maskCard
+ // -----------------------------------------------------------------------
+ describe('maskCard', () => {
+ it('should mask a 16-digit card number', () => {
+ expect(maskCard('4111111111111111')).toBe('**** **** **** 1111');
+ });
+
+ it('should mask a card number with dashes', () => {
+ expect(maskCard('4111-1111-1111-1111')).toBe('**** **** **** 1111');
+ });
+
+ it('should mask a card number with spaces', () => {
+ expect(maskCard('4111 1111 1111 1111')).toBe('**** **** **** 1111');
+ });
+
+ it('should mask a 15-digit card (Amex)', () => {
+ expect(maskCard('378282246310005')).toBe('**** **** **** 0005');
+ });
+
+ it('should return original if less than 4 digits', () => {
+ expect(maskCard('123')).toBe('123');
+ });
+
+ it('should return empty string for empty input', () => {
+ expect(maskCard('')).toBe('');
+ });
+
+ it('should handle null-like values gracefully', () => {
+ expect(maskCard(null as unknown as string)).toBe('');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // maskPhone
+ // -----------------------------------------------------------------------
+ describe('maskPhone', () => {
+ it('should mask a Spanish phone with country code', () => {
+ // +34 prefix detected (greedy 1-3 digits), rest masked keeping last 3
+ const result = maskPhone('+34612345678');
+ expect(result).toContain('+34');
+ expect(result.endsWith('678')).toBe(true);
+ expect(result).toContain('***');
+ });
+
+ it('should mask a phone without country code', () => {
+ // 612345678 → 9 digits, last 3 = 678, masked = 6 → ** ***
+ expect(maskPhone('612345678')).toBe('*** *** 678');
+ });
+
+ it('should mask a US phone with +1', () => {
+ // +1 + 2025551234 (10 digits) → last 3 = 234, masked = 7 → ** *** *
+ const result = maskPhone('+12025551234');
+ expect(result).toContain('+1');
+ expect(result.endsWith('234')).toBe(true);
+ });
+
+ it('should mask a UK phone with +44', () => {
+ // +44 + 2071234567 (10 digits) → last 3 = 567, masked = 7 → ** *** *
+ const result = maskPhone('+442071234567');
+ expect(result).toContain('+44');
+ expect(result.endsWith('567')).toBe(true);
+ });
+
+ it('should return original if less than 4 digits', () => {
+ expect(maskPhone('12')).toBe('12');
+ });
+
+ it('should return empty string for empty input', () => {
+ expect(maskPhone('')).toBe('');
+ });
+
+ it('should handle null-like values gracefully', () => {
+ expect(maskPhone(null as unknown as string)).toBe('');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // maskEmail
+ // -----------------------------------------------------------------------
+ describe('maskEmail', () => {
+ it('should mask a standard email', () => {
+ expect(maskEmail('user@mail.com')).toBe('u***@mail.com');
+ });
+
+ it('should mask a short local part', () => {
+ expect(maskEmail('ab@example.org')).toBe('a***@example.org');
+ });
+
+ it('should mask a single-char local part', () => {
+ expect(maskEmail('a@test.com')).toBe('a***@test.com');
+ });
+
+ it('should mask a long local part', () => {
+ expect(maskEmail('john.doe.smith@company.co.uk')).toBe('j***@company.co.uk');
+ });
+
+ it('should return original if no @ sign', () => {
+ expect(maskEmail('invalid-email')).toBe('invalid-email');
+ });
+
+ it('should return original if @ is first char', () => {
+ expect(maskEmail('@domain.com')).toBe('@domain.com');
+ });
+
+ it('should return empty string for empty input', () => {
+ expect(maskEmail('')).toBe('');
+ });
+
+ it('should handle null-like values gracefully', () => {
+ expect(maskEmail(null as unknown as string)).toBe('');
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // maskIban
+ // -----------------------------------------------------------------------
+ describe('maskIban', () => {
+ it('should mask a Spanish IBAN (24 chars, no spaces)', () => {
+ // ES9121000418450200051332 → 24 chars, middle = 16, 4 groups
+ expect(maskIban('ES9121000418450200051332')).toBe('ES91 **** **** **** **** 1332');
+ });
+
+ it('should mask a Spanish IBAN (with spaces)', () => {
+ expect(maskIban('ES91 2100 0418 4502 0005 1332')).toBe('ES91 **** **** **** **** 1332');
+ });
+
+ it('should mask a German IBAN (22 chars)', () => {
+ // DE89370400440532013000 → 22 chars, middle = 14, ceil(14/4) = 4 groups
+ const result = maskIban('DE89370400440532013000');
+ expect(result.startsWith('DE89')).toBe(true);
+ expect(result.endsWith('3000')).toBe(true);
+ expect(result).toContain('****');
+ });
+
+ it('should mask a UK IBAN (22 chars)', () => {
+ const result = maskIban('GB29NWBK60161331926819');
+ expect(result.startsWith('GB29')).toBe(true);
+ expect(result.endsWith('6819')).toBe(true);
+ });
+
+ it('should return original if less than 8 chars', () => {
+ expect(maskIban('ES91210')).toBe('ES91210');
+ });
+
+ it('should return empty string for empty input', () => {
+ expect(maskIban('')).toBe('');
+ });
+
+ it('should handle null-like values gracefully', () => {
+ expect(maskIban(null as unknown as string)).toBe('');
+ });
+ });
+});
diff --git a/packages/core/src/lib/security/pii/pii.utils.ts b/packages/core/src/lib/security/pii/pii.utils.ts
new file mode 100644
index 0000000..b5fd782
--- /dev/null
+++ b/packages/core/src/lib/security/pii/pii.utils.ts
@@ -0,0 +1,126 @@
+/**
+ * Masks a Spanish NIF/NIE/CIF.
+ *
+ * Keeps the last 3 characters visible.
+ *
+ * @example
+ * ```typescript
+ * maskNif('12345678A'); // '******78A'
+ * maskNif('X1234567L'); // '******67L'
+ * ```
+ */
+export function maskNif(value: string): string {
+ if (!value || value.length < 4) return value ?? '';
+ const visible = value.slice(-3);
+ const masked = '*'.repeat(value.length - 3);
+ return masked + visible;
+}
+
+/**
+ * Masks a credit/debit card number.
+ *
+ * Shows only the last 4 digits in standard card format.
+ *
+ * @example
+ * ```typescript
+ * maskCard('4111111111111111'); // '**** **** **** 1111'
+ * maskCard('4111-1111-1111-1111'); // '**** **** **** 1111'
+ * ```
+ */
+export function maskCard(value: string): string {
+ if (!value) return '';
+ const digits = value.replace(/\D/g, '');
+ if (digits.length < 4) return value;
+ const last4 = digits.slice(-4);
+ return `**** **** **** ${last4}`;
+}
+
+/**
+ * Masks a phone number.
+ *
+ * Keeps the country code prefix and last 3 digits visible.
+ *
+ * @example
+ * ```typescript
+ * maskPhone('+34612345678'); // '+34 *** *** 678'
+ * maskPhone('612345678'); // '*** *** 678'
+ * ```
+ */
+export function maskPhone(value: string): string {
+ if (!value) return '';
+ const digits = value.replace(/\D/g, '');
+ if (digits.length < 4) return value;
+
+ // Detect country code prefix (starts with + in original value)
+ if (value.startsWith('+')) {
+ const prefixMatch = value.match(/^\+(\d{1,3})/);
+ if (prefixMatch) {
+ const prefix = `+${prefixMatch[1]}`;
+ const rest = digits.slice(prefixMatch[1].length);
+ if (rest.length < 3) return value;
+ const last3 = rest.slice(-3);
+ const maskedLen = rest.length - 3;
+ return `${prefix} ${formatMaskedGroups(maskedLen)} ${last3}`;
+ }
+ }
+
+ // No country code
+ const last3 = digits.slice(-3);
+ const maskedLen = digits.length - 3;
+ return `${formatMaskedGroups(maskedLen)} ${last3}`;
+}
+
+/** Formats masked characters into groups of 3 separated by spaces. */
+function formatMaskedGroups(count: number): string {
+ const full = Math.floor(count / 3);
+ const remainder = count % 3;
+ const groups: string[] = [];
+ for (let i = 0; i < full; i++) groups.push('***');
+ if (remainder > 0) groups.push('*'.repeat(remainder));
+ return groups.join(' ');
+}
+
+/**
+ * Masks an email address.
+ *
+ * Shows the first character of the local part and the full domain.
+ *
+ * @example
+ * ```typescript
+ * maskEmail('user@mail.com'); // 'u***@mail.com'
+ * maskEmail('ab@example.org'); // 'a***@example.org'
+ * ```
+ */
+export function maskEmail(value: string): string {
+ if (!value) return '';
+ const atIndex = value.indexOf('@');
+ if (atIndex < 1) return value;
+ const local = value.slice(0, atIndex);
+ const domain = value.slice(atIndex);
+ const firstChar = local[0];
+ return `${firstChar}***${domain}`;
+}
+
+/**
+ * Masks an IBAN (International Bank Account Number).
+ *
+ * Shows the country code + check digits (first 4) and last 4 characters.
+ *
+ * @example
+ * ```typescript
+ * maskIban('ES9121000418450200051332'); // 'ES91 **** **** **** 1332'
+ * maskIban('ES91 2100 0418 4502 0005 1332'); // 'ES91 **** **** **** 1332'
+ * ```
+ */
+export function maskIban(value: string): string {
+ if (!value) return '';
+ const clean = value.replace(/\s/g, '');
+ if (clean.length < 8) return value;
+ const prefix = clean.slice(0, 4);
+ const last4 = clean.slice(-4);
+ const middleLen = clean.length - 8;
+ // Group middle chars in blocks of 4, each replaced with ****
+ const groups = Math.ceil(middleLen / 4);
+ const maskedGroups = Array(groups).fill('****').join(' ');
+ return `${prefix} ${maskedGroups} ${last4}`;
+}
diff --git a/packages/core/src/lib/security/provide-security.ts b/packages/core/src/lib/security/provide-security.ts
new file mode 100644
index 0000000..8ae85c7
--- /dev/null
+++ b/packages/core/src/lib/security/provide-security.ts
@@ -0,0 +1,35 @@
+import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
+import { SecurityConfig } from './security.types';
+import { SecurityService, SECURITY_CONFIG } from './security.service';
+
+/**
+ * Configure the security module.
+ *
+ * Provides `SecurityService` and optionally sets `SecurityConfig`
+ * for CSRF protection and PII masking behavior.
+ *
+ * **Important:** This does NOT register `csrfInterceptor`. CSRF
+ * protection requires the interceptor to be added separately:
+ *
+ * ```ts
+ * import { csrfInterceptor, provideSecurity } from '@fireflyframework/core';
+ *
+ * export const appConfig: ApplicationConfig = {
+ * providers: [
+ * provideHttpClient(withInterceptors([csrfInterceptor])),
+ * provideSecurity({ csrf: { enabled: true } }),
+ * ],
+ * };
+ * ```
+ *
+ * @param config - Optional security configuration. Defaults apply if omitted.
+ * @returns EnvironmentProviders to register in the application config
+ */
+export function provideSecurity(
+ config?: SecurityConfig,
+): EnvironmentProviders {
+ return makeEnvironmentProviders([
+ SecurityService,
+ ...(config ? [{ provide: SECURITY_CONFIG, useValue: config }] : []),
+ ]);
+}
diff --git a/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts b/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts
new file mode 100644
index 0000000..0e90b34
--- /dev/null
+++ b/packages/core/src/lib/security/sanitization/sanitize.utils.spec.ts
@@ -0,0 +1,161 @@
+import { sanitizeHtml, escapeXss, stripTags } from './sanitize.utils';
+
+describe('sanitize.utils', () => {
+ // -------------------------------------------------------------------
+ // sanitizeHtml
+ // -------------------------------------------------------------------
+ describe('sanitizeHtml', () => {
+ it('should preserve safe HTML tags', () => {
+ expect(sanitizeHtml('Hello world
'))
+ .toBe('Hello world
');
+ });
+
+ it('should remove '))
+ .toBe('Hi
');
+ });
+
+ it('should remove '))
+ .toBe('');
+ });
+
+ it('should remove