Security practices
What TDACorp Identity does with your credentials, your sessions, your tokens and your second factors, with every number taken from the code that enforces it. It describes behaviour that exists in pre-production today, not a roadmap, and it lists at length what is not claimed.
On this page
Draft
Scope and status
This page describes the security behaviour of TDACorp Identity, the shared sign-in, session, token and authorization service operated by TDACorp Private Limited (an Indian company, incorporated 17 July 2026). It covers the sign-in and account pages, the OpenID Connect and OAuth 2.0 provider endpoints, the account API, the administrative API and console, and the background jobs that serve them.
The platform is in pre-production. Where a protection is built and running, the page says so in the present tense. Where a protection is designed but not built, or is built and switched off, the page says that instead and repeats it in the final section. Nothing here is a statement about a product built on top of this platform: such a product enforces its own authorization in its own database and has its own notice.
| Item | Statement |
|---|---|
| Operator | TDACorp Private Limited |
| Last reviewed | 5 October 2026 |
| Lifecycle | Pre-production. Numbers below are what the code enforces today, not launch commitments |
| Evidence standard | Every figure is read from the constant or setting that enforces it, and a test fails when the page and the code drift apart |
| Independent assurance | None. No outside party has audited or certified this platform |
| Security contact | security@tdacorp.in (also published in the machine-readable security.txt file) |
Note
Session lifetimes, the step-up window and the other limits quoted on this page are the defaults every account receives today. Per-organization session lifetime, concurrent-session, minimum-assurance and allowed-method settings are read at sign-in for organization-scoped sign-ins, and the page quotes the defaults. The account pages use a fixed 30-minute freshness for second-factor checks.
Threat model summary
A written threat model exists in the repository, with numbered threats, the mitigation for each, the residual risk and the blast radius. The table below summarises it in plain words. Status uses three words only: Mitigated (the control is built and tested), Partly mitigated (built, with a stated gap) and Open (a known gap with no control yet).
| Threat | What stops it | Status |
|---|---|---|
| Learning whether an address has an account | Sign-in always performs one Argon2id comparison, real or against a dummy hash; registration, magic-link and recovery answer identically for known and unknown addresses; email-sending endpoints wait out a response-time floor | Partly mitigated: the floor hides differences smaller than itself; the timing of other endpoints is not measured |
| Credential stuffing and password guessing | Rate limits on sign-in attempts, counted in the database | Partly mitigated: attempts are throttled and there is no permanent account lockout; the owner is emailed when repeated failed attempts hit the account |
| Weak or breached password | Length and local weak-password rules plus a Have I Been Pwned range check | Partly mitigated: the breach check depends on an external service and is best effort |
| Stolen session cookie | HttpOnly, Secure, host-locked cookie; server-side session lookup on every request; short idle and absolute lifetimes; step-up for durable changes | Partly mitigated: a stolen live session can still read and revoke |
| Stolen refresh token | Rotation on every use; reuse of a retired token revokes the whole family and session | Mitigated, with a 10-second retry grace that a thief could exploit if they replay inside it |
| Stolen access token replayed from another machine | Sender-constraint through DPoP for every client except first-party web clients; 15-minute ceiling | Mitigated for constrained clients |
| Replay of a link or code | Single-use claims done as one conditional statement; magic and recovery links bound to the requesting browser; GET requests consume nothing | Mitigated |
| Authorization-code interception | 60-second code, PKCE, exact redirect URI, atomic single redemption | Mitigated |
| Cross-site request forgery | SameSite=Lax cookies plus a same-origin check on every state-changing request | Mitigated, relying on every supported browser sending an Origin or Sec-Fetch-Site header |
| Cross-site scripting and framing | Per-response nonce Content-Security-Policy, no inline styles, frame-ancestors none | Partly mitigated: browser behaviour of the policy is recorded from the specification more than from browser tests |
| Hijacked session minting durable access | Step-up with a passkey or authenticator code for credential, export, token and device-trust changes | Mitigated |
| Cloned passkey authenticator | Signature counter must advance; refusal and audit event otherwise | Partly mitigated: synced passkeys keep a zero counter and cannot be checked this way |
| Database read-only compromise | Passwords hashed with Argon2id; session, refresh and token secrets stored as digests; recoverable secrets sealed with AES-256-GCM | Partly mitigated: the key-wrapping key is held by a software provider in pre-production |
| Database plus key compromise | Designed for a real KMS | Open in pre-production: see the cryptography section |
| Audit log tampering | Append-only at the database, rows chained by hash, signed anchors | Partly mitigated: anchoring is not scheduled in the committed environment files |
| Cross-organization data access | Row-level security keyed on the transaction's organization | Mitigated, with the platform role bypassing it by design under an audit guard |
| Webhook delivery used as a server-side request forgery vector | Blocked address ranges, resolved-address check, https only, per-send deadlines | Partly mitigated: the IPv4 side is a deny-list |
| Forged forwarded-address headers evading per-address limits | One resolver determines the client address | Open until verified against the real edge: the resolution has not been measured on a live deployment |
| Outbound email used up by an attacker | Caps on outbound account email | Partly mitigated: the caps are best effort under concurrent requests |
| Compromised dependency | Install-time and CI supply-chain gates | Partly mitigated: a compromised maintainer of a pinned dependency is accepted risk |
Authentication factors compared
Passwords, passkeys, authenticator codes, recovery codes, magic links and federated sign-in each offer a different level of protection. The assurance level (AAL) follows the NIST SP 800-63B vocabulary used by the platform specification.
| Factor | Typical assurance | Phishing resistant | How it is stored | Limits and notes |
|---|---|---|---|---|
| Passkey (WebAuthn) | AAL2 when user verification is performed, which the platform requires | Yes: bound to the site's relying-party identity | Public key only; the private key never leaves your authenticator | Challenges are 32 random bytes, valid 5 minutes, single use. Accepted algorithms: EdDSA, ES256, RS256. Residential key and user verification are required |
| Password | AAL1 on its own | No | Argon2id hash with a random salt and recorded parameters | 12 to 256 characters after Unicode normalization. Checked against a breach corpus |
| Authenticator app (TOTP) | Second factor, giving AAL2 with a password | No | Secret sealed with AES-256-GCM under a purpose-specific data key | RFC 6238: SHA-1, 6 digits, 30-second step, one step either side, 20-byte secret. A matched time step is recorded and never accepted again |
| Recovery codes | Back-up for an authenticator app and an input to recovery | No | HMAC-SHA-256 keyed hash under a secret that is itself sealed | Batches of 10, single use, 10 characters of base32 each; a new batch supersedes the old one |
| Magic link by email | AAL1 | No | SHA-256 digest of the link secret | Valid 15 minutes, single use, completes only in the browser that asked for it |
| Federated sign-in (Google, GitHub) | AAL1 unless the provider asserts multi-factor from a trusted provider | Depends on the provider | A stored issuer and subject pair; no provider password is held | Exists in code but offered only if an operator registers a provider; the subprocessor page lists none today. Does not auto-link by email match unless an operator turns on an off-by-default two-level opt-in (a platform flag and an organization setting) and the address is verified on both sides; the owner is emailed when it happens |
| Step-up (re-proof of presence) | Raises the session's assurance for a window | Passkey: yes. Authenticator code: no | Evidence row with an expiry | Window 10 minutes by default; see the step-up section |
Note
No SMS or voice code is offered as a factor. The platform specification excludes SMS because of SIM-swap risk.
Password handling
How a password is stored
A password is put through Argon2id with parameters pinned in this platform's own code rather than left to a library default, so a routine dependency upgrade cannot quietly weaken them. The parameters are recorded with each hash, and an account's hash is upgraded to the current parameters the next time its owner signs in successfully.
| Parameter | Value | Notes |
|---|---|---|
| Algorithm | Argon2id | Memory-hard, resistant to GPU guessing |
| Memory | 64 MiB | Pinned in code |
| Passes (time cost) | 3 | Pinned in code |
| Lanes (parallelism) | 4 | Pinned in code |
| Salt | 16 random bytes | New for each hash |
| Stored form | PHC string | Parameters travel with the hash |
| Comparison | Constant time | An unknown identifier still runs one full hash comparison against a fixed dummy hash |
| Imported legacy hashes | A bcrypt-shaped hash is never verified | The attempt spends the same dummy work, answers like a wrong password, and is audited; the owner must set a new password through an emailed link or recovery |
What a new password must satisfy
The same policy applies when you register, set or change a password, accept an invitation, or finish recovery. It is deliberately small: it avoids composition rules and refuses only what no person would call a password.
| Rule | Value | Applies to |
|---|---|---|
| Minimum length | 12 characters after Unicode normalization (NFC) | Every surface that lets you choose a password |
| Maximum length | 256 characters; a raw field over 1,024 is refused before normalization, never truncated | Every such surface |
| Distinct characters | At least 4 distinct code points | Every such surface |
| Repeated unit | A password made only of one repeated unit of up to 4 characters is refused | Every such surface |
| Walks | A whole-string alphabet or keyboard-row run (for example a run along a keyboard row) is refused | Every such surface |
| Common choices | A short list of the commonest twelve-plus-character passwords is refused | Every such surface |
| Not applied at sign-in | An older imported password of 8 to 11 characters can still be verified | Sign-in only |
| Breach check | Refused if found in the Have I Been Pwned corpus | See below |
Breached-password checking
When you register, set or change a password, accept an invitation, or finish recovery, the new password is checked against the Have I Been Pwned corpus using its range API. Only the first five hexadecimal characters of a SHA-1 digest of the password leave this system, with a padded response so its size reveals nothing. The comparison happens here, and no account identifier or email address is sent.
| Behaviour | Detail |
|---|---|
| What leaves the system | A five-character prefix of a SHA-1 digest, never the password, the full digest or an identifier |
| Re-check | Every successful password sign-in re-runs the check in the background; a breached verdict writes an audit event that your Activity page shows with a prompt to change the password. It never delays the sign-in |
| Stored at rest | Neither the password, its hash nor the prefix is logged or stored by this check |
Sessions and cookies
A session is a server-side record, referenced by an opaque cookie. Only a SHA-256 digest of the cookie value is stored. Every request to the account pages looks the session up in the database rather than trusting the cookie alone, so revoking a session, or deactivating an account, takes effect on the next request.
Cookie attributes
| Cookie | Purpose | Lifetime | Attributes |
|---|---|---|---|
__Host-tdaid_sid | Your signed-in session | Sent for the session's absolute lifetime; the server ends it earlier on idle expiry or revocation | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-tdaid_ltx | A sign-in in progress | 10 minutes | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-tdaid_rdev | Binds a recovery link to the browser that asked | 30 days | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-device_id | Lets the account recognise a browser it has seen; a random value, not personal data | 400 days | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-tdaid_regb and __Host-tdaid_regv | Bind a registration code and a verified link to the registering browser | 30 minutes for the first, 15 minutes for the second | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-tdaid_fed | State for a federated sign-in in progress | 10 minutes | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
__Host-tdaid_pk_offer | Remembers that a passkey offer was shown so it is not repeated at every sign-in | 7 days | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain |
| Admin console session cookie | The administrative console's own session, a separate project | Up to 7 days as an upper bound; the access token and refresh token lifetimes gate real access | Encrypted (AES-256-GCM, direct key) rather than signed in plain text; host-locked in production. A second encrypted cookie holds a console sign-in in progress for 10 minutes |
The __Host- prefix makes a browser refuse the cookie unless it is Secure, has path / and has no Domain attribute, so a sibling subdomain cannot set or overwrite it.
Session lifetimes and limits
| Property | Value | Applies to |
|---|---|---|
| Idle timeout | 12 hours | Default |
| Absolute lifetime | 14 days | Default |
| Platform bounds in the specification | Idle at most 24 hours, absolute at most 30 days | Design bounds |
| Rotation of the cookie secret | On privilege change: login completion, step-up and organization switch | Session cookie |
| Login transaction | 10 minutes | Sign-in in progress |
| Remember-me | Off unless an operator enables a flag; when on it is bounded by the configured session days and a ceiling | Sign-in |
| Device binding of the session | Off unless an operator enables a flag | Session continuity |
| Concurrent sessions | Not limited by default | Account |
What ends sessions
| Event | Effect on sessions |
|---|---|
| You revoke one session | That session and its token family end |
| You choose to sign out everywhere | Every session, this one included |
| Password change, passkey removal, authenticator reset | Every other session and its refresh tokens end, outstanding sign-in links and open recovery requests are cancelled, and the session that made the change survives with a rotated cookie secret so a copy of the old cookie stops working. Personal access tokens are revoked only when the flow says so |
| Reverting a primary email change from the old address | Signs everything out |
| You choose "No, sign that session out" on a sign-in alert | The alerted session and every session the same browser opened since end; that device is forgotten and every other device drops from trusted to known |
| You choose "No, secure my account" on a sign-in alert | Every session, refresh family and personal access token ends; devices are forgotten; pending recovery requests and outstanding sign-in links are cancelled; you are emailed |
| Account deactivation | Every session, token and personal access token is revoked at once |
| A principal or organization is suspended | The session is refused on the next request, and an introspecting resource server sees the token as inactive |
| Back-channel logout | Registered applications are told the session ended |
Tokens issued to applications
| Token | Lifetime | Binding and handling |
|---|---|---|
| Access token | At most 15 minutes (a hard ceiling that a client's own setting can only lower) | Carries exactly one audience; bound to a key held by the client (DPoP, or mutual TLS where an operator enables it) unless the client is a first-party web application |
| Refresh token, first-party client | Sliding window set per client, never beyond the session's absolute expiry | Opaque, stored by hash; rotates on every use |
| Refresh token, third-party client | 7 days sliding and 30 days absolute at most | Same rotation; the absolute limit never exceeds the session's |
| Authorization code | 60 seconds | Stored as a digest, requires the PKCE verifier and the exact registered redirect URI, redeemed atomically; a second redemption revokes the family the first created |
| Pushed authorization request | 60 seconds | Single use |
| Client credentials token (service clients) | 15 minutes, no refresh | Sender-constrained |
| Back-channel logout token | 15 minutes | Signed, delivered through a retrying outbox |
| Personal access token | Up to 90 days, never longer | Format tdaid_pat_; shown once, stored only as a hash; can carry an IP allowlist; creating or revoking needs step-up |
| Client assertion (private key JWT) | At most 5 minutes | Signed by the client's registered key |
| DPoP proof | Issued-at time accepted within 60 seconds either side of the server clock; the jti is remembered for 5 minutes | A server-supplied nonce is required |
| Signing key | Rotated on a 30-day period by a scheduled job, with an overlap of 15 minutes plus 1 day before a retired key leaves the key set | Algorithm EdDSA (Ed25519); ES256 optional per client |
Rotation and reuse detection
Refresh tokens rotate on every use, and a token that was already used is remembered. If a retired token is presented again after a 10-second retry window, that is the signature of a stolen copy being replayed: the whole token family and its session are revoked, the account's authorization version is bumped so tokens already issued stop being accepted by anything that checks, and the event is audited. The short window exists only for the legitimate case of a client retrying after a dropped response. The sender-constraint check is applied after reuse detection, so a stolen token presented under the thief's key still counts as reuse.
After a reuse, a background job also ends every other session, refresh token family and personal access token of the account (only those created before the reuse) and emails the account owner that a reused sign-in token ended their access as a precaution. That email is a security notice that cannot be switched off.
Step-up re-authentication
A session is a bearer credential: anything that can replay it speaks with your full authority while it lives. So the operations that would turn a short-lived theft into durable access ask you to prove presence again with a passkey or an authenticator code (or, for an account with a linked provider, a sign-in through that provider).
| Property | Value |
|---|---|
| Default step-up window | 10 minutes |
| Bounds in the specification | 1 to 30 minutes per organization; the account pages apply a fixed 30-minute freshness, and the window setting is read only for organization administrator writes |
| Freshness required for second-factor operations on the account pages | The session must be at assurance level 2 or higher and the sign-in or step-up that justifies it must be no more than 30 minutes old |
| Methods | Passkey; authenticator code (only on a session that began with a password); linked provider |
| A recovery code alone | Never enough to regenerate recovery codes or remove the authenticator: a live authenticator proof is needed |
| Rate limit | Step-up attempts are rate limited per account |
Operations that need a fresh second factor
| Operation | Needs step-up | Notes |
|---|---|---|
| Add, rename or remove a passkey | Yes | You are emailed when a passkey is added or removed |
| Set, change or remove a password | Yes | Changing the password also asks for the current password under its own limit |
| Enrol or remove an authenticator app | Yes | |
| Regenerate recovery codes | Yes | Supersedes the old batch |
| Change the primary email address | Yes | The old address gets a 24-hour link that reverts the change and signs everything out |
| Request or download a personal-data export | Yes | |
| Deactivate the account | Yes | A typed confirmation phrase is also required |
| Create or revoke a personal access token | Yes | |
| Mark a device as trusted | Yes | |
| Link or unlink a federated account | Yes | |
| Register or edit an OAuth client of your own | Yes | Available only when an operator enables the feature |
| Revoke a single session | No: a live session is enough | An attacker with a session could therefore end your other sessions; an operator flag can require step-up here |
| View sessions, activity and connected apps | No |
Risk engine and sign-in alerts
At the end of every sign-in, after the factors have been checked, a risk engine assesses the attempt and decides to allow it, to demand a stronger factor, or to refuse it. An unfamiliar browser, place or pattern may be asked to confirm with a stronger factor. It is a rule-based score, not a machine-learning model, and it never uses advertising or behavioural profiling data.
Every outcome and the signals behind it are stored with the sign-in transaction and audited.
| Sign-in alert behaviour | Detail |
|---|---|
| Trigger | A sign-in from a browser the account has not seen |
| What is sent | An email to your verified addresses with a browser label, country, network address and sign-in method |
| Record lifetime | The alert record is deleted when it is used or after 7 days |
| The "Review this sign-in" link and its "No, sign that session out" and "No, secure my account" buttons | Scanner-safe: opening the link changes nothing; the change happens on a confirmation POST. Every invalid, expired or used link gets the same page |
| Owner decision pending | An account with no second factor may be admitted on an unrecognised browser and alerted, instead of refused; the permanent default is not decided |
Note
The sign-in alert is an email about a sign-in you may or may not have made. Separate notices are also emailed for a detected token reuse and for a sign-in blocked by the risk engine (these notices are rate limited per account).
Rate limits and abuse controls
Sign-in, recovery, token and API endpoints are rate limited. The counters are kept in the database so concurrent attempts cannot slip past a shared counter, and a request that exceeds a limit is refused with HTTP 429 and a Retry-After header. Account email sends are capped. The exact limits are not published.
| Additional control | Detail |
|---|---|
| Lockout | None. Repeated failure is throttled; there is no permanent lockout. The owner is emailed when repeated failed attempts hit the account |
| Response-time floor | A minimum response time on the email-sending endpoints, hiding a faster path for existing accounts |
| Request body limit | 256 KiB for API requests |
| Header block limit | 8 KiB |
| Request timeout | 10 seconds |
| Outbound account email | Capped per account and per address. Account security notices (credential changes, session endings, sign-in alerts, token reuse, deactivation, export) are sent directly and do not use these caps, so an attacker cannot silence your alerts |
Recovery paths
Account recovery is ordered by strength, and the flow offers only the strongest path available to you. Every recovery link is single use, valid 15 minutes, and bound to the browser that requested it (a link opened elsewhere requires a new request).
| Path | Requirement | Effect | Status |
|---|---|---|---|
| R1 | Another enrolled passkey | Ordinary sign-in; no recovery needed | Built |
| R2 | A recovery code and a link to a verified email | A limited session confined to the security page until a factor is re-enrolled | Built |
| R3 | A link to a verified email only | A 24-hour hold; every verified address is emailed a hold notice with a one-click cancel link, and a signed-in session sees a pending-recovery notice with a cancel button on the Security page; after the hold, the same limited session | Built |
| R4 | Administrator-assisted recovery | An administrator starts recovery for an account; every verified address of the target gets a hold notice with a one-click cancel, and the target then asks again from their own browser after the hold. The specification adds dual control for the internal organization | Built only for a single-administrator case on the internal organization, behind an operator flag that is off by default; dual control is not claimed |
| Recovery detail | Value |
|---|---|
| Recovery link lifetime | 15 minutes |
| Hold for the R3 path | 24 hours |
| Window to re-open a held recovery | 7 days |
| Settled recovery rows | Deleted 14 days after completion or cancellation |
| Magic sign-in link lifetime | 15 minutes |
| Email verification link lifetime | 24 hours; an unverified address is invisible to sign-in and swept after 24 hours |
| Invitation lifetime | 7 days, single use |
Cryptography and key management
| Purpose | Primitive | Notes |
|---|---|---|
| Token signing, audit anchors, security event tokens, logout tokens | Ed25519 (EdDSA); ES256 optional per client | Each signing key row carries a purpose and the issuers it may sign for; a validly signed token of the wrong type or issuer is rejected |
| Data export signing | A key of purpose export | Signature and document hash are over the plaintext so a download verifies offline |
| Envelope encryption of recoverable secrets | AES-256-GCM, random 96-bit nonce, additional data bound to the row | Per-purpose data keys wrapped by a key-encryption key |
| Password hashing | Argon2id, 64 MiB, 3 passes, 4 lanes | See the password section |
| Opaque secret storage (sessions, codes, links, tokens) | SHA-256 over 32 random bytes | Fixed 32-byte hash domain |
| Recovery codes | HMAC-SHA-256 under a pepper held as a sealed secret | |
| Randomness | Operating-system CSPRNG | 32 bytes for every secret |
| Secret comparison | Constant time | Length mismatch handled before the comparison |
| Webhook signatures | HMAC-SHA-256 over timestamp and body | Receivers are told to reject skew above 5 minutes |
| TLS | Terminated at the hosting edge | HSTS with a two-year max-age and includeSubDomains is sent by the application |
| Post-quantum signatures | Not offered | No standard algorithm identifier exists yet; the key set stays crypto-agile |
Key hierarchy
| Layer | What it is | Where it lives today |
|---|---|---|
| Key-encryption key (KEK) | Wraps every data key | A master key held by the hosting platform's environment as a sensitive variable |
| Data keys (DEKs) | One per purpose: authenticator secrets, client secrets, recovery pepper, webhook secrets, refresh successors | Wrapped rows in the database |
| Signing keys | Private key material for tokens, anchors, logout and security event tokens | AES-256-GCM sealed rows in the database, opened under the master key |
| Export artifact keys | A fresh random 256-bit key per export, wrapped by the key provider | Inside each stored artifact for at most 7 days |
| Public keys | The published key set (JWKS), cached up to 1 hour | Served from the database |
Pre-production caveat
In this period the key-management provider is a software provider, not a cloud KMS or a hardware module. Whoever obtained both the database and the master key could forge tokens and open the wrapped secrets until the keys were rotated. A write attacker is not stopped by encryption at all, because the published key set is read from the database. That is weaker than the design for production, which uses a real KMS. A cloud KMS provider is not implemented. Replacing the master key is an operator procedure (re-wrapping every data key under a new key, then rotating the signing keys). The production project holds a single master key rather than a key ring, and that procedure has not been run. The deviation is recorded with exit criteria in the repository.
Secrets at rest
| Secret | At-rest form | Reversible |
|---|---|---|
| Password | Argon2id hash | No |
| Session secret | SHA-256 digest | No |
| Refresh token | SHA-256 digest; the successor token is sealed to support the retry grace | Successor only |
| Authorization code, PAR reference, magic link, recovery link, invitation, verification link | SHA-256 digest | No |
| Personal access token | SHA-256 digest; shown once | No |
| Authenticator (TOTP) secret | Sealed with AES-256-GCM | Yes, to verify codes |
| Recovery codes | HMAC-SHA-256 keyed hash | No |
| Passkey | Public key only | Not secret |
| Client secret (confidential clients) | Sealed | Yes |
| Webhook secret | Sealed | Yes, to sign deliveries |
| Federation client secret | Sealed | Yes |
| Queued outbound email | Sealed while queued; finished rows are purged | Yes, until sent |
| Personal-data export | Sealed per artifact, then deleted after the download window | Yes, for the owner's download |
| Network address of a session | Stored plain, then cleared by a retention job after 90 days; ended sessions are deleted after 90 days | See the privacy page |
A database test census fails the build when a column that looks like a plaintext secret appears, and a database check on every ciphertext column refuses a value too short to be a real ciphertext, so a plaintext string written into a ciphertext column fails at the database.
Transport and headers
| Header or control | Value | Where |
|---|---|---|
| Content-Security-Policy | default-src 'none'; scripts only from this site with a per-response nonce and 'strict-dynamic'; styles only from this site; fonts, images (this site and data URIs) and connections limited to this site; frame-ancestors 'none'; base-uri 'none'; form-action 'self' on account and trust pages | Account, sign-in and trust pages |
| Frame embedding | X-Frame-Options: DENY and frame-ancestors 'none' | All pages |
| MIME sniffing | X-Content-Type-Options: nosniff | All pages |
| Referrer | Referrer-Policy: no-referrer | All pages |
| Cross-origin opener | Cross-Origin-Opener-Policy: same-origin | All pages |
| Browser features | Permissions-Policy disables camera, microphone, geolocation, payment and USB | All pages |
| HSTS | max-age=63072000; includeSubDomains (two years), production builds only | All pages |
| Caching | A response without a cache directive is no-store; only the key set, discovery documents, avatars and static assets are cacheable, and a cacheable response that sets a cookie is forced back to no-store | Everywhere |
| Inline scripts and styles | No style attribute anywhere in the web app and no inline event handlers; a lint rule enforces it | Web app |
| Third-party resources | No third-party scripts, fonts, images or analytics | All pages |
| Admin console headers | A policy that forbids framing and locks the base URI, object sources and form action, no sniffing, no referrer, a restrictive Permissions-Policy, same-origin opener and resource policies, and two-year HSTS | Admin console; it has no script policy yet, which needs a per-request nonce |
| Redirects | Only to a registered redirect URI; the post-login target in the account area is a closed list, never a supplied URL | OAuth and account |
| Same-origin check | Every state-changing account and sign-in request refuses a cross-origin request before doing anything | Account API and /auth/v1 |
| Logout | A cookie-only GET shows a confirmation page and ends nothing; a session ends at once only for an ID token hint naming the signed-in principal or a POST carrying a token a cross-site form cannot compute | OAuth logout |
| Request hygiene | JSON only, content type enforced, strict schemas that reject unknown keys, prototype-pollution keys stripped | API |
Audit log
Security-relevant events are written to an append-only log in which each row is chained to the previous row by a hash. The database refuses UPDATE and DELETE on it. Each event carries identifiers and hashes rather than names or email addresses; an address in a payload appears only as a hash plus its last two characters.
| Namespace | What it records |
|---|---|
auth | Sign-in started, identify, password and passkey results, passkey counter regression, authenticator results, recovery code use, step-up, risk outcomes and blocks, session created and revoked, logout, magic links, recovery started, cancelled and completed, federated sign-in |
cred | Password set, changed or removed; passkey added, removed or renamed; authenticator enrolled or reset; recovery codes regenerated; email added, verified, made primary or removed; federated account linked or unlinked |
dir | Principal created, suspended, reactivated, deactivated and erased; organization and membership changes; invitations; service accounts, workloads and agents |
oauth | Client created, updated and keyed; codes and tokens issued, refreshed, exchanged and revoked; refresh reuse; consent granted and revoked; back-channel logout |
authz | Authorization decisions that are not low-risk allows; role and binding changes; policy lifecycle; elevations; recertification; impersonation start and end; containment denials |
key | Signing key generated, promoted, retiring and retired; emergency rotation; data and key-encryption key rotation |
platform | Every use of the platform role that bypasses row-level security; break-glass activation and deactivation; configuration changes; migrations |
audit | Anchors published; verification results; exports requested and completed |
webhook | Webhook created, updated, deleted; final delivery failures |
| Property | Value |
|---|---|
| Chain | Each row's hash is the SHA-256 of the previous hash and the canonical form of the row |
| Anchors | The chain head can be signed with an anchor key and published; a verification command checks each published anchor against a public key obtained outside the database |
| Scheduled anchoring | A worker exists, but it is off by default and no publication target is configured in the committed environment files, so anchoring is currently an operator command |
| Offline verification | An operator command verifies anchors; a standing scheduled verification is not built |
| Retention default | 2,920 days (8 years) |
| Retention bounds | The validated range is 365 to 3,650 days, but the runtime reads one deployment-wide setting, not a per-organization one; the internal organization's floor is 2,920 days |
| Pruning | A compaction job exists but is off unless an operator enables it, and it only acts below the newest signed anchor with a database floor of 365 days. Today audit records are retained without automatic pruning |
| Your own events | Visible on the Activity page for the last 90 days |
Administrative and break-glass controls
| Control | What is built |
|---|---|
| Administrative API access | Only tokens issued to the administrative console client, or a named automation client with a proof of possession, are accepted; anything else is refused. While the console client is unset, every administrative request is refused |
| Admin console | A separate deployment that signs in through the same OpenID Connect flow with a private key assertion and keeps its session in an encrypted cookie |
| High-risk administrative writes | Need an active elevation or a fresh second factor of the caller's own; an elevation's requester, beneficiary and approver must be distinct |
| Platform role | The role that can bypass row-level security is fenced: every write must be preceded by a platform-access audit row in the same transaction |
| Break-glass | A separate console-only session, refused by the account API. Online activation needs hardware-key assertions from two distinct live platform administrators; the weaker approval-token mode is refused. While the platform has exactly one platform administrator, an offline operator command with direct database access and the master key can activate it with the second administrator waived |
| Support impersonation | The target is emailed when a support access window starts and when it ends |
| First platform administrator | Provisioned by the owner with an operator command. An optional allowlist, off by default and not set in the committed configuration, can grant the first administrator at sign-in to a verified address when no administrator exists yet |
| Delegation | An actor can only grant what it may itself delegate; wildcards never cover authorization-management permissions; an agent's authority is a subset of its delegator's |
| Operator command-line tool | Reads the key-encryption key from the environment only; credential files are created with owner-only permissions |
Note
Operators of this platform are a very small group. The break-glass design assumes their devices are safe. A break-glass activation raises an email alert to the operator address, but only when the daily monitor job runs, so it can arrive up to about a day later.
Supply chain and dependency policy
| Rule | How it is enforced |
|---|---|
| Locked dependencies | A committed lockfile; CI installs with a frozen lockfile and fails if it disagrees with the manifests |
| Fresh releases are held back | A 7-day minimum release age, asserted against the effective configuration |
| No install scripts by default | Only a reviewed allowlist of packages may run build scripts; a bypass in any configuration layer fails the gate before install |
| No resolution rewriting | Overrides, patches, tarball or git sources and lockfile entries without an integrity hash fail the gate |
| Pinned package manager | An exact pnpm version asserted in CI and in the hosting install command |
| Advisory scanning | An advisory scan on every change to dependencies and weekly, with a tracked allowlist |
| Dependency review | Adding, removing or changing a version in the cryptography, OIDC, authentication or authorization packages fails until the reviewed table is edited in the same commit |
| One version per package | A single specifier per external package across the workspace |
| Small cryptographic closure | The cryptography package's full production dependency closure is capped (currently one package) |
| Internal packages never reach a registry | Internal references must use the workspace protocol |
| No database-push tool | A command that applies schema changes blindly is banned from every manifest and the lockfile, after an outage it caused in the earlier platform; schema changes are numbered, checksummed SQL migrations |
| Deployments | Only the main branch deploys; preview deployments are off and secrets are scoped to Production only |
| Automated updates | Weekly, grouped, with a cooldown matching the release-age rule |
Logging and observability
| Property | Detail |
|---|---|
| Request log | One JSON line per request on standard output, filtered through a closed list of fields |
| Redaction | A key outside the list, or a value outside its declared domain, becomes [redacted] |
| Metrics and traces | Exported over OTLP only when an endpoint is configured |
| Operational alerting | A daily monitor can email an operator address; it needs a daily scheduled job to run |
| Logged field | Meaning |
|---|---|
| Timestamp | Epoch milliseconds from the server clock |
| Request identifier | A random identifier minted by the server |
| Trace identifier | A W3C trace id |
| Route | The matched route pattern, never the requested path or query |
| Method and status | HTTP method and response status |
| Duration | Wall-clock milliseconds |
| Organization identifier | An identifier, not a person |
| Client identifier | An OAuth client id from a verified authentication |
| Caller | A truncated keyed hash (HMAC) of the caller's address bucket; it correlates one caller without revealing an address |
| Error class | The class name only; the message never leaves |
| Outcome | One of ok, denied, rate limited, client error, server error |
| Deployment identifier | From the environment |
| Never logged | Why |
|---|---|
| Passwords, password hashes and breach-check prefixes | Credentials |
| Cookie values, bearer tokens, refresh tokens, codes and links | Credentials |
| Raw email addresses | Personal data; audit payloads use a hash and a two-character mask |
| Raw network addresses in the request log | Replaced by the keyed hash above |
| Error messages | They can act as an oracle |
| Request paths and query strings | They can carry secrets |
A support tool strips credentials, tokens, cookies, private key material, password hashes and email addresses from any HAR file or log bundle before it is stored, and refuses images and archives rather than pretend to scan them.
Backups and disaster recovery
| Item | As documented |
|---|---|
| Primary database | A managed Postgres service |
| Provider point-in-time window | Not measured for this deployment: an earlier project measured six hours, and the figure for the current project is unverified |
| Nightly encrypted dump | A workflow is written that dumps with a dedicated login, encrypts to a public key and uploads to object storage; it has not been run yet |
| Owner copy | A command and a timer exist for the owner's own copy; the timer is documented, not installed |
| Monthly restore drill | A workflow is written that restores the newest dump into a scratch database and runs the schema check; it has not been run yet |
| Missed-backup alert | The backup workflow is written to ping an external healthcheck service, and has not yet run. The monitor's own stale-backup check is off in the committed staging configuration, which is the one production runs |
| Key-encryption key | Never stored with a dump |
| Recovery point objective | Not claimed; inside the provider's window it is seconds in principle, outside it up to a day once the owner's timer is installed |
| Recovery time objective | Not measured on real data; no figure is claimed |
| Data location | The primary database is in AWS US East 1 (N. Virginia), not in India; the placement the specification calls for was waived by the owner for this period |
Testing and assurance
| Activity | State |
|---|---|
| Unit and integration tests | Run in CI on every push against a real Postgres cluster started for the tests; no containers |
| Security regression register | A mapped list of threat tests; the build fails if a listed test is missing or skipped |
| Named invariants | Rules such as single-audience tokens, sender-constraint, refresh reuse revocation and secrets-at-rest each have a test |
| Browser end-to-end tests | A real-browser suite drives sign-in, registration, recovery, step-up, sessions, tokens, privacy actions and consent |
| Accessibility checks | An automated axe scan at WCAG 2.2 AA tags in light and dark preference at two widths; manual screen-reader and zoom checks are still owed |
| Fuzzing | Specified for parsers; do not read this as a claim that fuzzing runs |
| Internal review | The repository records internal security audits and a re-architecture review; findings and what was deferred are written down |
| External penetration test | None |
| Certifications | None |
| OpenID conformance certification | Not obtained |
Incident handling
Runbooks exist for signing key compromise, a client key compromise, a database read breach, an administrator account compromise, an audit chain mismatch, exposed support material and a locked-out sole administrator, plus a notification runbook. Each step in them is tagged as built or not built, so none describes a future state in the present tense. The specification names reporting clocks (6 hours to the national CERT and 72 hours to the Data Protection Board for the classes it lists); the notification runbook carries a template for both. The vulnerability disclosure page says how to report a problem to us, and the incident response page describes how an incident is handled.
What you can do to stay safe
| Action | Why it helps |
|---|---|
| Add a passkey | It is phishing resistant and satisfies a second factor on its own |
| Use a password manager and a long unique password | The platform rejects the weakest passwords but cannot know reuse |
| Enrol an authenticator app and keep your recovery codes offline | Recovery codes are shown once |
| Open the Sessions page after travel or a new phone | Revoke sessions and devices you do not recognise |
| Read sign-in alert emails | Use "No, sign that session out" if you did not sign in |
| Keep the mailbox that receives recovery links secure | Anyone who controls it can start recovery by design; the 24-hour hold and your notices are the safeguard |
| Prefer short-lived personal access tokens with an IP allowlist | The ceiling is 90 days; shorter is safer |
| Review Connected apps | Revoke any application you no longer use |
| Report anything odd to security@tdacorp.in | See the vulnerability disclosure page |
What this page does not claim
- TDACorp holds no security certification. There is no SOC 2 report, no ISO 27001 certificate and no third-party audit.
- No outside party has verified these statements. They are written by the team that maintains the code, from the code.
- This page describes the identity platform only, in pre-production. A product built on this platform enforces its own authorization in its own database.
- It is not a threat model, a penetration test report or a claim that the platform is unbreakable.
- Production hardware or cloud key management is not in place. Pre-production runs a software key provider, and the weaker custody described in the cryptography section is real.
- No uptime or recovery objective is promised. Service level objectives exist as design targets and only some of the metrics behind them are emitted.
- Background work (exports, erasure, key rotation, webhook delivery, expiry sweeps) is driven by a daily scheduled job and by ordinary traffic on the current hosting plan, so it can run up to about a day late.
- There is no permanent account lockout; failed attempts are throttled, and the owner is emailed when repeated failed attempts hit the account.
- A break-glass activation is alerted to the operator only by the daily monitor job, so the alert can be up to about a day late.
- The breach check is best effort: it depends on an external service.
- The resolution of the client address used by per-address limits has not been verified against the live edge.
- Scheduled audit anchoring and automatic audit pruning are off in the committed configuration, and offline standing verification is not built.
- Backup jobs are written but have not been run, and no restore time has been measured.
- Federated sign-in code exists but no provider is registered, so none is offered today.
- Revoking a single session does not need step-up unless an operator enables the flag.
- Imported older password hashes are not verified; those accounts must set a new password.
- The cost of the decision API's event stream on the hosting plan has not been measured.
- The platform has not been through the OpenID Foundation conformance suite.
- Audit retention is not a setting an organization can change; the page quotes defaults for session lifetimes and the step-up window.
The disclosure policy exists because we expect to be wrong somewhere. If you have found where, tell us at security@tdacorp.in.