secure-ui-components
v0.1.7
Published
Security-first web component library with zero dependencies
Maintainers
Readme
secure-ui-components
Security-first Web Component library with built-in behavioral telemetry. Zero dependencies.
Live Demo — Try all components in your browser.
Features
- 10 Secure Components — Input, Textarea, Select, Form, File Upload, DateTime, Table, Submit Button, Card, Telemetry Provider
- 4-Tier Security System —
public,authenticated,sensitive,critical - Behavioral Telemetry — Every field collects typing patterns, paste detection, dwell time, and correction signals automatically
- Risk Scoring —
<secure-form>aggregates field signals into a session-level risk score at submission - Signed Envelopes —
<secure-telemetry-provider>detects automation/headless browsers and signs every submission with HMAC-SHA-256 - Zero Dependencies — Pure TypeScript, no runtime dependencies
- Progressive Enhancement — All components render meaningful markup and work without JavaScript
- CSP-Safe — Styles loaded via
<link>from'self'; nounsafe-inlinerequired - SSR Friendly — Adopts server-rendered markup on upgrade; no document access in constructors
- Fully Customisable — CSS Design Tokens +
::part()API - Comprehensive Testing — 869 tests, 80%+ branch coverage
Philosophy: Security Telemetry as a First-Class Primitive
Traditional form security stops at validation and CSRF protection. Secure-UI goes further — every form submission carries a behavioral fingerprint that travels alongside the user's data, giving the server the context it needs to distinguish real users from bots and credential stuffers in a single atomic request.
Field interaction → Behavioral signals → Risk score → Signed envelope
(SecureBaseComponent) (SecureForm) (SecureForm) (SecureTelemetryProvider)Layer 1 — Field-level signals (SecureBaseComponent)
Every secure field silently records: dwell time from focus to first keystroke, typing velocity, correction count (backspace/delete), paste detection, autofill detection, focus count, and blur-without-change patterns.
Layer 2 — Session aggregation (<secure-form>)
At submission, the form queries getFieldTelemetry() from every child field, produces per-field snapshots, and computes a composite risk score from 0–100. The telemetry payload travels alongside form data in a single fetch request as _telemetry.
Layer 3 — Environmental signals (<secure-telemetry-provider>)
An optional overlay that wraps <secure-form>. Monitors for WebDriver/headless flags, DOM script injection (via MutationObserver), devtools, suspicious screen dimensions, and pointer/keyboard activity. Signs the final envelope with HMAC-SHA-256 so the server can detect replay attacks.
What the server receives (enhanced submission):
{
"email": "[email protected]",
"password": "...",
"_telemetry": {
"sessionDuration": 14320,
"fieldCount": 2,
"riskScore": 5,
"riskSignals": [],
"submittedAt": "2026-03-12T18:30:00.000Z",
"fields": [
{
"fieldName": "email",
"fieldType": "secure-input",
"dwell": 420,
"completionTime": 3100,
"velocity": 4.2,
"corrections": 1,
"pasteDetected": false,
"autofillDetected": false,
"focusCount": 1,
"blurWithoutChange": 0
}
],
"_env": {
"nonce": "a3f9...",
"issuedAt": "2026-03-12T18:30:00.000Z",
"environment": {
"webdriverDetected": false,
"headlessDetected": false,
"mouseMovementDetected": true,
"pointerType": "mouse"
},
"signature": "7d3a..."
}
}
}Risk signals
| Signal | Condition | Score |
|--------|-----------|-------|
| session_too_fast | Submitted in under 3 s | +30 |
| session_fast | Submitted in under 8 s | +10 |
| all_fields_pasted | All fields pasted, no keystrokes | +25 |
| field_filled_without_focus | Any field has focusCount = 0 | +15 |
| high_velocity_typing | Any field velocity > 15 ks/s | +15 |
| form_probing | Field focused/blurred > 1× with no input | +10 |
| high_correction_count | Any field with > 5 corrections | +5 |
| autofill_detected | All fields autofilled (trust signal) | −10 |
Installation
npm install secure-ui-componentsQuick Start
With telemetry (recommended)
<secure-telemetry-provider signing-key="your-per-session-secret">
<secure-form action="/api/login" method="POST" csrf-token="..." enhance>
<secure-input label="Email" name="email" type="email" required security-tier="authenticated"></secure-input>
<secure-input label="Password" name="password" type="password" required security-tier="critical"></secure-input>
<secure-submit-button label="Sign in" loading-label="Signing in…"></secure-submit-button>
</secure-form>
</secure-telemetry-provider>document.querySelector('secure-form').addEventListener('secure-form-submit', (e) => {
const { formData, telemetry } = e.detail;
console.log('Risk score:', telemetry.riskScore);
console.log('Risk signals:', telemetry.riskSignals);
});Bundler (Vite, Webpack, Rollup)
import 'secure-ui-components/secure-input';
import 'secure-ui-components/secure-form';
import 'secure-ui-components/secure-telemetry-provider';CDN / Vanilla HTML
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/secure-ui-components/dist/styles/tokens.css">
</head>
<body>
<secure-input label="Email" name="email" type="email" required></secure-input>
<script type="module">
import 'https://unpkg.com/secure-ui-components/dist/index.js';
</script>
</body>
</html>Security Tiers
All components accept a security-tier attribute. The default is critical.
| Tier | Level | Masking | Autocomplete | Rate Limit | Audit |
|------|-------|---------|--------------|------------|-------|
| public | 1 | off | on | off | minimal |
| authenticated | 2 | off | on | off | changes + submission |
| sensitive | 3 | partial (last 4 chars) | off | 10/min | full |
| critical | 4 | full | off | 5/min | full |
<secure-input security-tier="public" label="Username" name="username"></secure-input>
<secure-input security-tier="sensitive" label="Card Number" name="card"></secure-input>
<secure-input security-tier="critical" type="password" label="Password" name="password"></secure-input>The security-tier attribute is immutable after connection — changes after connectedCallback are silently ignored (fail-secure).
Components
<secure-input>
Text input with XSS prevention, masking, password strength validation, rate limiting, and automatic telemetry collection.
Attributes
| Attribute | Type | Description |
|-----------|------|-------------|
| label | string | Visible field label |
| name | string | Form field name |
| type | string | Input type — text, password, email, tel, number, url, search |
| placeholder | string | Placeholder text |
| required | boolean | Mark as required |
| disabled | boolean | Disable the field |
| readonly | boolean | Make read-only |
| pattern | string | Regex validation pattern |
| minlength | number | Minimum character length |
| maxlength | number | Maximum character length |
| autocomplete | string | Autocomplete hint (overridden to off for sensitive/critical) |
| value | string | Initial value |
| security-tier | string | public | authenticated | sensitive | critical |
Properties & Methods
const el = document.querySelector('secure-input');
el.value // get current value (unmasked)
el.value = 'foo' // set value programmatically
el.valid // boolean — passes all validation rules
el.name // field name string
el.getAuditLog() // AuditLogEntry[]
el.clearAuditLog()
el.getFieldTelemetry() // FieldTelemetry — behavioral signals for this field
el.focus()
el.blur()Events
| Event | Detail |
|-------|--------|
| secure-input | { name, value, masked, tier } |
| secure-audit | { event, tier, timestamp, … } |
Example
<secure-input
label="Password"
name="password"
type="password"
required
security-tier="critical"
></secure-input><secure-textarea>
Multi-line input with real-time character counter, rate limiting, and automatic telemetry collection.
Attributes: label, name, placeholder, required, disabled, readonly, minlength, maxlength, rows, cols, wrap, value, security-tier
Properties & Methods: value, name, valid, getAuditLog(), clearAuditLog(), getFieldTelemetry(), focus(), blur()
Events: secure-textarea → { name, value, tier }
<secure-textarea
label="Description"
name="description"
maxlength="500"
rows="5"
></secure-textarea><secure-select>
Dropdown with option whitelist validation — prevents value injection. Telemetry collected on change events.
Attributes: label, name, required, disabled, multiple, size, value, security-tier
Properties & Methods
el.value // current value (comma-separated for multiple)
el.selectedOptions // string[] of selected values
el.valid
el.name
el.addOption(value, text, selected?)
el.removeOption(value)
el.clearOptions()
el.getAuditLog()
el.getFieldTelemetry()
el.focus()
el.blur()Events: secure-select → { name, value, tier }
<secure-select label="Country" name="country" required>
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</secure-select>Light DOM <option> children are transferred to the shadow DOM automatically. Only values added via <option> or addOption() are accepted.
<secure-form>
Form container with CSRF protection, field validation, behavioral telemetry aggregation, and optional fetch-enhanced submission.
<secure-form>uses light DOM (no Shadow DOM) for native form submission compatibility.
Attributes
| Attribute | Description |
|-----------|-------------|
| action | Form submission URL |
| method | HTTP method (default POST) |
| enctype | Encoding type |
| csrf-token | CSRF token value injected as a hidden field |
| csrf-field-name | Hidden field name (default csrf_token) |
| csrf-header-name | Also send CSRF token in this request header |
| novalidate | Disable browser constraint validation |
| enhance | Enable fetch-based JSON submission instead of native |
| security-tier | Security tier |
Properties & Methods
el.valid // true if all secure child fields pass validation
el.securityTier
el.getData() // { fieldName: value, … } including CSRF token
el.reset()
el.submit() // programmatic submit (triggers validation + telemetry)Events
| Event | Detail |
|-------|--------|
| secure-form-submit | { formData, formElement, telemetry, preventDefault() } — cancelable |
| secure-form-success | { formData, response, telemetry } — only when enhance is set |
telemetry shape (SessionTelemetry):
{
sessionDuration: number; // ms from form mount to submission
fieldCount: number;
fields: FieldTelemetrySnapshot[];
riskScore: number; // 0–100
riskSignals: string[]; // e.g. ['session_too_fast', 'all_fields_pasted']
submittedAt: string; // ISO 8601
}Submission modes
- Without
enhance— native browser form submission. Values from shadow DOM inputs are synced to hidden<input type="hidden">fields automatically. Telemetry is available insecure-form-submitbut not sent to the server. - With
enhance— intercepts submit, validates all fields, sends{ ...formData, _telemetry }as JSON viafetch. Full telemetry payload travels to the server in the same request.
Example
<secure-form action="/api/register" method="POST" csrf-token="abc123" enhance>
<secure-input label="Email" name="email" type="email" required></secure-input>
<secure-input label="Password" name="password" type="password" required security-tier="critical"></secure-input>
<secure-submit-button label="Register"></secure-submit-button>
</secure-form>form.addEventListener('secure-form-submit', (e) => {
const { formData, telemetry } = e.detail;
// Block high-risk submissions before they reach your server
if (telemetry.riskScore >= 50) {
e.detail.preventDefault();
showChallenge();
return;
}
// Log signals for your fraud pipeline
analytics.track('form_submit', {
risk: telemetry.riskScore,
signals: telemetry.riskSignals,
});
});<secure-telemetry-provider>
Optional overlay that wraps <secure-form> to add environmental signals and HMAC-SHA-256 signing to every submission envelope.
Place this as the outer wrapper. It monitors the entire document for automation markers and DOM tampering during the session.
Attributes
| Attribute | Description |
|-----------|-------------|
| signing-key | HMAC-SHA-256 key — must also be known server-side to verify the signature |
Properties & Methods
const provider = document.querySelector('secure-telemetry-provider');
provider.collectSignals() // EnvironmentalSignals — point-in-time snapshot
provider.getEnvironmentalSignals() // alias for collectSignals()
provider.sign(signals) // Promise<SignedTelemetryEnvelope>What it detects
| Signal | Description |
|--------|-------------|
| webdriverDetected | navigator.webdriver present or truthy |
| headlessDetected | HeadlessChrome in userAgent or missing window.chrome |
| domMutationDetected | New <script> element injected after page load |
| injectedScriptCount | Count of dynamically added <script> elements |
| devtoolsOpen | outerWidth − innerWidth > 160 or outerHeight − innerHeight > 160 |
| suspiciousScreenSize | Screen width or height is zero or < 100px |
| pointerType | Last pointer event type: mouse | touch | pen | none |
| mouseMovementDetected | Any mousemove event fired during session |
| keyboardActivityDetected | Any keydown event fired during session |
How the envelope is injected
The provider listens for secure-form-submit on itself (bubbles from the nested form). It calls sign() asynchronously and attaches the result as detail.telemetry._env. Since it mutates the same object reference, any handler that awaits a microtask after the event fires will see _env populated.
Signed envelope shape (SignedTelemetryEnvelope):
{
nonce: string; // 32-char random hex — detect replays
issuedAt: string; // ISO 8601
environment: EnvironmentalSignals;
signature: string; // HMAC-SHA-256 hex over nonce.issuedAt.JSON(environment)
}Security notes
- The
signing-keyis a symmetric secret. For strong guarantees, rotate it per-session via a server nonce endpoint rather than hardcoding it as an attribute. - All signals are heuristic — a determined attacker can spoof them. The value is raising the cost of scripted attacks.
- In non-secure contexts (
http://)SubtleCryptois unavailable; the signature will be an empty string. The server should treat unsigned envelopes with reduced trust.
Example
<secure-telemetry-provider signing-key="per-session-server-issued-key">
<secure-form action="/api/login" enhance csrf-token="...">
<secure-input label="Email" name="email" type="email" required></secure-input>
<secure-input label="Password" name="password" type="password" required security-tier="critical"></secure-input>
<secure-submit-button label="Sign in"></secure-submit-button>
</secure-form>
</secure-telemetry-provider>document.querySelector('secure-form').addEventListener('secure-form-submit', async (e) => {
const { telemetry } = e.detail;
// _env is populated async by the provider — wait a microtask
await Promise.resolve();
if (telemetry._env) {
console.log('Nonce:', telemetry._env.nonce);
console.log('Signature:', telemetry._env.signature);
// Verify signature server-side with the same key
}
});<secure-file-upload>
Drag-and-drop file upload with content validation and optional malware scan hook.
Attributes
| Attribute | Description |
|-----------|-------------|
| label | Visible label |
| name | Field name |
| accept | Accepted MIME types / extensions (e.g. image/*,.pdf) |
| max-size | Max file size in bytes |
| multiple | Allow multiple files |
| required | Mark as required |
| capture | Camera capture hint |
| security-tier | Security tier |
Size limits by tier (when max-size is not set)
| Tier | Limit |
|------|-------|
| public | 20 MB |
| authenticated | 10 MB |
| sensitive | 5 MB |
| critical | 2 MB |
Properties & Methods
el.files // FileList | null
el.valid
el.name
el.hasScanHook // boolean
el.scanning // boolean — true while scan hook is running
el.clear()
el.setScanHook(async (file) => { return { valid: true } })
el.getAuditLog()Scan hook
upload.setScanHook(async (file) => {
const result = await myApi.scanFile(file);
return { valid: result.clean, reason: result.threat };
});Events: secure-file-upload → { name, files: File[], tier }
Content validation (critical tier): Magic number verification for JPEG, PNG, and PDF files.
Filename validation: Blocks path traversal (e.g. ../), null bytes, and dangerous config file names.
<secure-file-upload
label="Upload Document"
name="document"
accept="image/*,.pdf"
max-size="5242880"
security-tier="sensitive"
></secure-file-upload><secure-datetime>
Date and time picker with format validation, range enforcement, and timezone display.
Attributes
| Attribute | Description |
|-----------|-------------|
| label | Visible label |
| name | Field name |
| type | date | time | datetime-local | month | week (default date) |
| min | Minimum date/time (ISO format) |
| max | Maximum date/time (ISO format) |
| step | Step value |
| value | Initial value (ISO format) |
| required | Mark as required |
| disabled | Disable the field |
| readonly | Make read-only |
| show-timezone | Display UTC offset alongside the input |
| security-tier | Security tier |
Properties & Methods
el.value // ISO string
el.value = '2025-06-15' // set value
el.valid
el.name
el.getValueAsDate() // Date | null
el.setValueFromDate(date) // accepts Date object, sets formatted value
el.getAuditLog()
el.getFieldTelemetry()
el.focus()
el.blur()Events: secure-datetime → { name, value, type, tier }
CRITICAL tier: Year must be between 1900 and 2100.
<secure-datetime
label="Appointment"
name="appointment"
type="datetime-local"
min="2025-01-01T00:00"
max="2030-12-31T23:59"
required
></secure-datetime><secure-table>
Data table with sorting, filtering, pagination, and column-level data masking.
Properties
table.data = [
{ id: 1, name: 'Alice', ssn: '123-45-6789' },
];
table.columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true, filterable: true },
{ key: 'ssn', label: 'SSN', tier: 'critical' }, // masked
];Column definition
| Property | Type | Description |
|----------|------|-------------|
| key | string | Data object key |
| label | string | Column header text |
| sortable | boolean | Enable click-to-sort |
| filterable | boolean | Include in global search |
| tier | string | Mask values at this tier level |
| width | string | CSS column width |
| render | (value, row, key) => string | Custom cell renderer |
Column masking
| Column tier | Behaviour |
|---------------|-----------|
| sensitive | Last 4 characters visible, rest masked (••••4567) |
| critical | Fully masked (••••••••) |
Events: table-action → { action, …data-attributes } — fired when an element with [data-action] inside a cell is clicked.
Progressive enhancement: Place a <table slot="table"> with data-key attributes on <th> elements inside <secure-table> — the component reads columns and data from the server-rendered markup.
table.columns = [
{
key: 'status',
label: 'Status',
render: (value) => `<span class="badge">${value}</span>`
}
];<secure-card>
Composite credit card form with a live 3D card preview, automatic card type detection, Luhn validation, expiry checking, and aggregate telemetry across all four fields. All inputs render inside a single closed Shadow DOM.
Security model:
- Full PAN and CVC are never included in events, audit logs, or hidden form inputs
- CVC uses native
type="password"masking — never visible on screen - Card number is masked to last-4 on blur
- Security tier is locked to
criticaland cannot be changed - All sensitive state is wiped on
disconnectedCallback - Telemetry from all four inputs (number, expiry, CVC, name) is aggregated into one composite behavioral fingerprint
Raw card data must be passed directly to a PCI-compliant payment processor SDK (e.g. Stripe.js, Braintree). Use
getCardData()for that handoff — never send raw card numbers or CVCs to your own server.
Attributes
| Attribute | Type | Description |
|-----------|------|-------------|
| label | string | Legend text displayed above the fields |
| name | string | Base name for hidden form inputs |
| show-name | boolean | Show the optional cardholder name field |
| disabled | boolean | Disable all fields |
| required | boolean | Mark fields as required |
Properties & Methods
const card = document.querySelector('secure-card');
card.valid // true when all visible fields pass validation
card.cardType // 'visa' | 'mastercard' | 'amex' | 'discover' | 'diners' | 'jcb' | 'unknown'
card.last4 // last 4 digits — safe to display and log
card.name // value of the name attribute
card.getCardData() // { number, expiry, cvc, name } | null — for payment SDK only
card.getFieldTelemetry() // composite behavioral signals across all 4 card inputs
card.reset()
card.focus()
card.getAuditLog()Events
| Event | Detail |
|-------|--------|
| secure-card | { name, cardType, last4, expiryMonth, expiryYear, cardholderName, valid, tier } |
| secure-audit | { event, tier, timestamp, … } |
Note: the secure-card event detail intentionally omits the full PAN and CVC.
CSS Parts
| Part | Element |
|------|---------|
| container | Outer wrapper |
| label | Legend element |
| wrapper | Input wrapper (per field group) |
| number-input | Card number <input> |
| expiry-input | Expiry <input> |
| cvc-input | CVC <input> |
| name-input | Cardholder name <input> |
| error | Error message container (per field) |
Card type detection
| Type | Detected prefix |
|------|----------------|
| Visa | 4 |
| Mastercard | 51–55, 2221–2720 |
| Amex | 34, 37 |
| Discover | 6011, 65xx |
| Diners | 300–305, 36, 38 |
| JCB | 2131, 1800, 35xxx |
Form participation
Three hidden inputs are created in the light DOM:
{name}— last 4 digits only (not full PAN){name}-expiry— MM/YY string{name}-holder— cardholder name
No hidden input is created for CVC.
Example
<secure-card name="payment" label="Card details" show-name></secure-card>// When the user clicks Pay — pass directly to your payment SDK
payButton.addEventListener('click', async () => {
const data = card.getCardData();
if (!data) return;
const token = await stripe.createToken({ number: data.number, exp_month: ..., cvc: data.cvc });
// Send token.id to your server — never data.number or data.cvc
});<secure-submit-button>
Accessible submit button with loading state and automatic form-validity gating.
Attributes: label, loading-label, disabled, security-tier
Properties
el.disabled
el.label
el.getAuditLog()Behaviour
- For
authenticated,sensitive, andcriticaltiers: button remains disabled until all required<secure-input>,<secure-textarea>,<secure-select>, and<secure-datetime>siblings reportvalid = true. - Shows a spinner and
loading-labelduring form submission. - Rate-limited at
sensitive/criticaltiers.
<secure-submit-button label="Submit" loading-label="Submitting…"></secure-submit-button>Common Attributes
All field components support:
| Attribute | Type | Description |
|-----------|------|-------------|
| label | string | Visible field label |
| name | string | Form field name |
| required | boolean | Mark field as required |
| disabled | boolean | Disable the field |
| readonly | boolean | Make the field read-only |
| security-tier | string | public | authenticated | sensitive | critical (default: critical) |
Common Properties & Methods
All field components expose these in addition to component-specific methods:
el.value // get/set current value
el.valid // boolean — passes all validation rules
el.name // field name string
el.securityTier // current security tier
el.getAuditLog() // AuditLogEntry[]
el.clearAuditLog()
el.getFieldTelemetry() // FieldTelemetry — behavioral signals (no raw values)
el.focus()
el.blur()FieldTelemetry shape:
{
dwell: number; // ms from focus to first keystroke
completionTime: number; // ms from first keystroke to blur
velocity: number; // keystrokes per second
corrections: number; // backspace / delete event count
pasteDetected: boolean;
autofillDetected: boolean;
focusCount: number;
blurWithoutChange: number; // focused but left without typing
}Common Events
| Event | Fired by | Detail |
|-------|----------|--------|
| secure-input | <secure-input> | { name, value, masked, tier } |
| secure-textarea | <secure-textarea> | { name, value, tier } |
| secure-select | <secure-select> | { name, value, tier } |
| secure-datetime | <secure-datetime> | { name, value, type, tier } |
| secure-file-upload | <secure-file-upload> | { name, files, tier } |
| secure-form-submit | <secure-form> | { formData, formElement, telemetry, preventDefault() } |
| secure-form-success | <secure-form> | { formData, response, telemetry } |
| secure-card | <secure-card> | { name, cardType, last4, expiryMonth, expiryYear, cardholderName, valid, tier } |
| secure-audit | all components | { event, tier, timestamp, … } |
| table-action | <secure-table> | { action, row } |
Customisation
CSS Design Tokens
Override tokens at :root to theme all components globally:
:root {
--secure-ui-color-primary: #6366f1;
--secure-ui-input-border-radius: 8px;
--secure-ui-font-family-base: 'Inter', sans-serif;
}CSS Parts API
Style internal elements directly:
secure-input::part(label) {
font-weight: 700;
text-transform: uppercase;
}
secure-input::part(input) {
font-family: monospace;
}
secure-input::part(error) {
background: #ffe0e0;
padding: 0.5rem;
}Available parts on all components: container, label, wrapper, input / textarea / select, error
See the Customization Guide for a full token reference.
Per-Component Imports
Import only what you need:
import 'secure-ui-components/secure-input';
import 'secure-ui-components/secure-textarea';
import 'secure-ui-components/secure-select';
import 'secure-ui-components/secure-form';
import 'secure-ui-components/secure-file-upload';
import 'secure-ui-components/secure-datetime';
import 'secure-ui-components/secure-table';
import 'secure-ui-components/secure-card';
import 'secure-ui-components/secure-telemetry-provider';Or import everything at once:
import 'secure-ui-components';Testing
npm test # run all tests
npm run test:watch # watch mode
npm run test:coverage # coverage reportDocumentation
Contributing
Contributions are welcome. Please see the main repository for guidelines.
License
MIT — see LICENSE file for details.
