Security invariants, credential governance, trust boundary model, and access discipline for the A Square Solutions ecosystem. Documents the three-tier access architecture across TrustSeal and ScamCheck, all credentials and where they are allowed, the security implications of historical operational failures, silent security drift scenarios, and lightweight security observability patterns. Grounded entirely in real production architecture.
Security in this ecosystem is not a separate layer — it is an emergent property of the access architecture. The most dangerous security scenarios are not external attacks against hardened endpoints. They are internal configuration states where the system grants more access than intended, or where a credential reaches a context it should not be in. Both are silent. Neither produces a log entry.
This document formalizes the access model, credential inventory, security invariants, trust boundaries, and the conditions that produce silent security drift.
Every operation in the TrustSeal and ScamCheck systems executes under one of three trust tiers. The tier determines what Firestore paths are accessible, what operations are permitted, and what credentials are available.
Identity: Firebase service account auto-provisioned per project.
Access: Full read/write access to all Firestore collections, all Firebase Auth operations, all Storage objects. Security rules are bypassed entirely — the Admin SDK does not pass through Firestore rule evaluation.
Code that operates here: All Firebase Cloud Functions source code in functions/src/.
Trust basis: Server-side execution only. There is no client-facing path to Admin SDK code. The service account key is never exposed — Cloud Functions use the implicit credential from the Firebase execution environment.
Security property: Admin trust is exclusively structural. No user action can escalate to Tier 1 access through the application layer. The only path to Tier 1 is deployment of Cloud Functions code.
Identity: Firebase Auth UID from a signed-in user session (email/password or Google OAuth).
Access: Governed entirely by Firestore Security Rules. Current rules: read own quota document, read own profile. Writes to quota, subscription, and rate limit documents are blocked. All access requires a valid Firebase Auth token — no anonymous user gets Tier 2 access to data.
Code that operates here: React client application (src/).
Trust basis: Firebase Auth token issued after successful authentication. Token validated by Firebase on each SDK operation. Token expires; sessions are maintained by Firebase's client SDK refresh behavior.
Identity: No Firebase Auth token present.
Access: Zero Firestore access (no rules allow unauthenticated reads). Cloud Functions callable via onCall return HttpsError('unauthenticated') immediately before any processing. The only Tier 3 surface is the Razorpay webhook onRequest endpoint, which is secured by HMAC-SHA256 signature verification rather than Firebase Auth.
Trust basis: None. Assumed to be untrusted.
All credentials in the ecosystem, their classification, permitted locations, and security properties.
GEMINI_API_KEY
AIzaSy... (Google Cloud API key)REACT_APP_ variable, any NEXT_PUBLIC_ variable, any file committed to version control, any URL parameter, any client-side JavaScriptRAZORPAY_KEY_SECRET
[random string]RAZORPAY_WEBHOOK_SECRET
WordPress Application Password
RAZORPAY_KEY_ID (REACT_APP_RAZORPAY_KEY_ID)
Firebase Client Configuration (apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId)
apiKey in client configuration is not a secret. Firebase security is enforced by Firestore Security Rules and Firebase Auth, not by keeping the project configuration secret. These values are visible in the page source of every Firebase web app.apiKey is a project identifier, not an authorization credential.NEXT_PUBLIC_GA_MEASUREMENT_ID
Ten security invariants extracted from the real production architecture. Where a specific historical failure established an invariant, it is noted.
Statement: GEMINI_API_KEY, RAZORPAY_KEY_SECRET, and RAZORPAY_WEBHOOK_SECRET must never appear in any variable with a REACT_APP_, NEXT_PUBLIC_, or equivalent client-bundle-inclusion prefix. These secrets must only be readable by server-side execution environments.
Why: A secret baked into a client JavaScript bundle is publicly accessible to anyone who inspects the page source, the network tab, or decompiles the bundle. Client-side secrets provide zero protection.
Detection: Search the compiled client bundle (dist/assets/*.js) for the prefix pattern of each secret. For Gemini: AIzaSy. For Razorpay secret: the key format pattern. If either string appears in any client-side file, rotation is required immediately.
Establishing evidence: Architecture-based proactive invariant. Documented in Gemini Production Operations — server-side-only architecture section.
Statement: When rotating RAZORPAY_WEBHOOK_SECRET, the new value must be set in both Razorpay Dashboard and Firebase Functions environment simultaneously. There must be no window where the Dashboard has the new secret and the Cloud Function has the old one, or vice versa.
Why: A non-atomic rotation creates a window where all legitimate Razorpay webhooks fail signature verification (if Dashboard is updated first) or where a stale secret is still being accepted (if Functions are updated first and Dashboard lags). Either window is a service or security problem.
Rotation procedure:
firebase functions:config:set razorpay.webhook_secret="[new]"firebase deploy --only functionsStatement: The Firestore document at users/{uid}/quota/current with tier: 'premium' must only be written by the razorpayWebhook Cloud Function after successful HMAC-SHA256 signature verification. No client-side code path may write to this document.
Why: The client-side Razorpay payment handler (handler callback in checkout modal) fires after the user completes checkout from the user's perspective. This callback is client-controllable — it can be invoked programmatically without a real payment. Any write operation triggered from this callback is an unauthenticated privilege escalation vector.
Enforcement: Firestore security rules block all client writes to users/{uid}/quota/{doc} (allow write: if false). Even if client code attempted to write tier: 'premium', the rule would reject it. This provides defense in depth beyond the architectural invariant.
Verification: Confirm the handler function in the Razorpay checkout initialization contains only UX state updates. Search client source for any Firebase set() or update() calls inside the payment handler. There should be none.
Establishing evidence: Razorpay Subscription Integration with Firebase — payment handler section explicitly documents the prohibited pattern.
Statement: The Firestore security rules must contain explicit allow write: if false rules for all documents in the access-privilege path: users/{uid}/quota/{doc} and users/{uid}/subscription/{doc}. Default Firestore behavior (deny all) is not sufficient — explicit deny rules must be present to survive rules refactoring.
Why: Firestore's default behavior is to deny all access that is not explicitly permitted. However, a rules refactoring that adds a broad match pattern (e.g., match /users/{uid}/{document=**}) could inadvertently grant client write access to quota and subscription documents. Explicit deny rules create a second layer of protection against this misconfiguration.
Current state:
match /users/{uid}/quota/{doc} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false; // explicit deny — Cloud Functions admin SDK bypasses this
}
Verification: Before any Firestore rules deploy, confirm that allow write: if false is present on the quota and subscription document paths. Confirm no match /users/{uid}/{document=**} catch-all pattern that would override these rules.
Statement: Firestore security rules changes must be reviewed for permission regressions before every deploy. A rules deploy that broadens access to any document path is a security event, not just a configuration change.
Why: Firestore rules are the only access control layer between authenticated users and the data they are not permitted to write. A rules regression (unintentionally permitting writes) may not produce any log entry or observable error — users simply gain access they should not have.
Review criteria for a rules deploy:
write access that was previously if false?{document=**}) cover paths that previously had explicit deny rules?allow read authentication check?Establishing evidence: Firebase Functions 403 After Redeploy — rules deployment sequence matters operationally. The review requirement extends this: the correctness of the rules content matters as much as the deployment order.
Statement: The Firebase Auth Authorized Domains list must be reviewed when any deployment domain changes. Domains that are no longer in use must be removed. A domain that remains authorized after the associated deployment is decommissioned represents an ongoing attack surface.
Why: Any domain in the Authorized Domains list can initiate Firebase Auth operations against the project. A stale authorized domain (e.g., an old test subdomain, a retired developer's personal domain used during testing) that is no longer controlled by the project owner could be re-registered by an attacker and used to create or impersonate Firebase Auth sessions.
Current authorized domains (TrustSeal and ScamCheck):
trustseal.asquaresolution.com — production, activescamcheck.asquaresolution.com — production, active[username].github.io — GitHub Pages staging, active during developmentlocalhost — development, Firebase default, activeReview trigger: After any domain decommission, any project handover, any GitHub account change.
Establishing evidence: Firebase Auth Session Lost on Custom Domain — established that missing domains cause silent auth failures. The inverse (stale domains) creates silent attack surface.
Statement: Live production API keys (RAZORPAY_KEY_ID in live mode, any production webhook secrets) must be scoped to the Production environment only in Vercel. Preview deployments must use only test-mode credentials or have no credentials at all.
Why: Vercel preview URLs are accessible to anyone with the URL. Code running on a preview URL with a live RAZORPAY_KEY_ID has full production payment capability. A preview deployment running untested code with live credentials can make live payment API calls, create live subscriptions, or expose live credentials in error messages.
Current state: The AI Execution Lab (Next.js) uses Vercel. TrustSeal and ScamCheck deploy to GitHub Pages and Firebase, not Vercel — so the Vercel preview risk applies only to the Lab, which has no payment credentials. For any future product deployed to Vercel that includes payment integration, this invariant becomes critical.
Establishing evidence: GA4 Production Analytics Contaminated by Vercel Preview Deployments — establishes that preview deployments with production-scoped variables produce production-environment consequences.
Statement: The WordPress Application Password used for REST API automation must exist only in the developer's local environment. It must never appear in any repository file, .env file committed to git, CI/CD secret, or shared credential store.
Why: WordPress Application Passwords grant full API write access to the WordPress instance — posts, pages, settings, plugin configurations. A committed credential persists in git history even after deletion from the working tree. An exposed credential requires both immediate revocation (WordPress Admin → Application Passwords → revoke) and history scanning to confirm scope of exposure.
Safe pattern: The credential lives in a local .env file in the developer's home directory or a password manager. Scripts read it via environment variable. .env is in .gitignore. The .gitignore entry must be in place before any .env file is created.
Verification: git log --all --full-history --source -S "password_prefix" -- . to confirm no credential string appears in any commit.
Statement: Cloud Function code must never silently fall back to an insecure default when a required environment variable is missing. If RAZORPAY_WEBHOOK_SECRET is missing, the webhook handler must fail immediately with an error — it must not proceed with no signature verification.
Why: A missing-variable-in-production failure combined with a silent insecure fallback produces a security regression that passes all non-production tests. The environment-variable-missing failure class is documented as producing silent feature absence — but if the missing variable governs a security check, the failure is a security event, not just a feature gap.
Example of the dangerous pattern:
// DANGEROUS — falls through to no verification if secret missing
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET || ''
if (receivedSig !== expectedSig) { // always matches when secret is ''
// This branch is never taken — all requests pass "verification"
}
Correct pattern:
// CORRECT — fail loud on missing secret
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('RAZORPAY_WEBHOOK_SECRET not configured')
return res.status(500).json({ error: 'Webhook handler misconfigured' })
}
Establishing evidence: Environment Variable Missing in Production — the general failure class. Security framing: if the missing variable is a security control, the failure mode is an active security regression.
Statement: firebase-admin must never be imported in any client-side JavaScript file. The Admin SDK imports, the service account credentials it uses, and the admin-tier Firestore access it provides must remain exclusively in Cloud Functions source code.
Why: Importing firebase-admin in a browser-bundled file would either fail at runtime (server-only Node.js APIs) or expose service account credentials in the client bundle, depending on the bundler configuration. Either outcome is a security failure — one exposes admin credentials, one reveals that admin operations were attempted client-side.
Establishing evidence: Node.js fs Module Pulled into Client Bundle — the documented failure class where server-only modules enter client bundles. firebase-admin is a server-only module in the same class.
Firestore rules are the access control layer between Tier 2 (authenticated users) and data they should not modify. They are the only thing preventing a malicious authenticated user from writing arbitrary values to quota, subscription, or rate limit documents.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Quota: users read own, nobody writes from client (admin SDK bypasses)
match /users/{uid}/quota/{doc} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
}
// Rate limits: locked entirely to admin SDK
match /rateLimits/{ipHash}/{subcollection}/{doc} {
allow read, write: if false;
}
// All other paths: Firestore default deny (no explicit allow = no access)
}
}
Before every Firestore rules deploy:
☐ No new 'allow write' rules added to quota or subscription paths
☐ No wildcard catch-all pattern (/{document=**}) covering previously-denied paths
☐ All authentication checks use request.auth != null AND uid equality
☐ No 'allow read' added to paths previously set to 'if false'
☐ No rules removed without understanding what they protected
☐ Rules deploy sequence confirmed: rules before functions (INV-FB-1)
users/{uid}/subscription/{doc} — Subscription documents (written by webhook handler via Admin SDK) have no explicit client-side read rule documented. Default Firestore behavior denies client reads, but explicit rules should be added for clarity and resilience against future refactoring.
users/{uid}/checks/{doc} — Check history documents (written by Cloud Function, read by client for history display). Client reads are likely intentional, but the rule should be explicitly documented rather than relying on Firestore default behavior.
onCall)Trust model: Firebase validates the caller's auth token before the function handler executes. The request.auth object is populated only for authenticated users. The function performs a second explicit check: if (!request.auth?.uid) throw HttpsError('unauthenticated').
Defense layers: Firebase SDK auth validation (layer 1) + explicit uid check (layer 2) + per-user quota enforcement (layer 3 — blocks abuse even for authenticated users).
Attack surface: A Firebase Auth account with no verified email can reach Tier 2 function access. Bot account creation is the residual risk (documented in cost governance doctrine).
onRequest)Trust model: No Firebase Auth. Security relies entirely on HMAC-SHA256 signature verification using RAZORPAY_WEBHOOK_SECRET. Every request body is verified against the signature before any payload data is read.
The webhook URL is public. It is a Firebase Cloud Functions URL of the form https://[region]-[project-id].cloudfunctions.net/razorpayWebhook. Anyone can discover it by inspecting the client bundle or by guessing the pattern. This is expected — the security model does not depend on URL secrecy.
What the signature check actually protects: The shared secret (RAZORPAY_WEBHOOK_SECRET) is known only to Razorpay and the Firebase Function. Only Razorpay can produce a valid HMAC-SHA256 signature for a given request body using this secret. Any forged request will fail signature verification and receive a 400 response before any Firestore write occurs.
Single point of failure: If RAZORPAY_WEBHOOK_SECRET is exposed, this trust boundary collapses entirely. Rotation procedure is defined in INV-SEC-2.
Trust model: Implicit service account credentials from the Firebase execution environment. No explicit key file. No client-accessible path.
Attack surface: The only path to Admin SDK access is deploying Cloud Functions code. This requires Firebase project owner/editor IAM permissions — a separate access control layer outside the application's security architecture.
Each historical operational failure analyzed for hidden security implications.
| Failure | Security implication | Severity |
|---|---|---|
| firebase-auth-domain-not-authorized | Inverse: stale domains in Authorized Domains list = ongoing attack surface from uncontrolled domains | Medium |
| razorpay-test-live-key-mismatch | Test-mode client key in browser with live-mode server: inconsistency creates confusion about which environment user activity is attributed to | Low |
| firebase-deploy-sequence-auth-failure | During the rules-stale window, old rules were enforced against new function code — if old rules were more permissive, this was a brief privilege elevation period | Low (rules were restrictive in both versions) |
| ga4-preview-environment-contamination | Establishes the pattern: Vercel preview deployments with production-scoped variables create production-environment side effects. If any production secret had that scope, the security consequence would be severe | High (precedent) |
| environment-variable-missing-production | If the missing variable governs a security check (e.g., RAZORPAY_WEBHOOK_SECRET), absence must fail loud — not silently skip the check | Critical (pattern) |
| gemini-rate-limit-429-no-ux | Client retry storm behavior demonstrates that unauthenticated or weakly-authenticated surfaces under user control can create resource amplification | Low (Gemini, not a data security issue) |
| firebase-functions-node-version-stability | A Cloud Function that crashes immediately on invocation provides no security — it also performs no quota enforcement, no signature verification, no auth check. A crash-on-invoke is a defense-in-depth failure | Medium (defense absent during outage) |
Configuration states that degrade security posture without producing any observable signal.
Scenario: A rules refactor adds match /users/{uid}/{document=**} { allow read: if request.auth.uid == uid; } to simplify the rules file. This pattern, while granting only read access, inadvertently overrides the existing explicit allow write: if false on quota documents — because Firestore uses the most permissive matching rule when multiple rules apply.
Signal: None. The change produces no error. Existing read operations continue working. Only a deliberate test of write operations from the client SDK would reveal the regression.
Detection: Firestore rules review checklist (INV-SEC-5) applied before every deploy. Specifically: confirm no wildcard pattern covers previously-explicit paths.
Scenario: A developer with access to the Firebase project leaves. The RAZORPAY_WEBHOOK_SECRET is known to them (they set it, or saw it in Firebase Functions config). No rotation occurs. The secret remains valid.
Signal: None. The webhook continues functioning normally. No log entry indicates stale access.
Detection: Rotation trigger defined: after any Firebase project access change, RAZORPAY_WEBHOOK_SECRET is rotated as a standard offboarding step.
Scenario: During development, a temporary test domain was added to Authorized Domains and never removed. That domain expired and was re-registered by an unknown party. The new owner of that domain can now initiate Firebase Auth operations against the TrustSeal or ScamCheck project.
Signal: None. Firebase Auth functions normally. The stale domain creates no log entry.
Detection: Periodic review of Authorized Domains list (INV-SEC-6). Any domain not currently associated with an active deployment must be removed.
Scenario: A developer creates a .env file in the project root with GEMINI_API_KEY=AIzaSy... and commits it before checking .gitignore. Git history is permanent — even if the file is removed in the next commit, the key is in the repository history indefinitely.
Signal: None at commit time. The file is committed like any other change. GitHub may issue a secret scanning alert (if secret scanning is enabled), but this is not configured by default on private repositories.
Detection: git log --all -p -- .env | grep AIzaSy — scan history for key format patterns before any repository visibility change (making repo public, sharing with collaborators). Pre-commit hook that rejects GEMINI_API_KEY= patterns in committed files.
Scenario: During a Firebase Functions environment configuration update, RAZORPAY_WEBHOOK_SECRET is accidentally omitted. The function is redeployed. If the webhook handler falls back to an empty string comparison: webhookSecret || '', then receivedSig !== expectedSig evaluates against an empty-string HMAC — which always fails, meaning signature verification is effectively disabled for all requests.
Signal: None if the fallback accepts all signatures. Razorpay webhook delivery log continues showing 200 responses. All webhook events are processed. The security regression is completely invisible.
Detection: INV-SEC-9 (fail loud on missing secret). Explicit null check for RAZORPAY_WEBHOOK_SECRET at function startup. Confirmation that the null check is present in code review before any Functions deploy.
Methods for detecting security drift without enterprise tooling.
After every production build, scan the compiled client bundle for server-side secret patterns:
# Check compiled TrustSeal/ScamCheck bundle for Gemini key pattern
grep -r "AIzaSy" dist/assets/
# Check for Razorpay key secret (won't have rzp_ prefix — check for secret format)
# This is harder to pattern-match; rely on the architectural invariant instead
Frequency: Before every GitHub Pages deploy. Takes 30 seconds.
Firebase Console → Authentication → Settings → Authorized Domains
Review all listed domains. Any domain not associated with an active, controlled deployment must be removed.
Frequency: Quarterly or after any deployment domain change.
Before every firebase deploy --only firestore:rules (or any combined deploy):
1. Read the full rules file
2. Confirm no new write permissions on quota/subscription paths
3. Confirm no catch-all patterns override explicit denies
4. Run the rules checklist (see above)
Frequency: Before every rules deploy.
Vercel Dashboard → Project → Settings → Environment Variables
For any variable containing a live API key or secret: confirm only "Production" checkbox is checked. Preview and Development must have either test-mode credentials or no value.
Frequency: After any environment variable change. Monthly quick review.
After any Firebase Functions deploy:
# Confirm webhook secret is configured (don't print the value)
firebase functions:config:get razorpay.webhook_secret | grep -q "." && echo "SET" || echo "MISSING"
Frequency: After any Firebase Functions environment configuration change.
GEMINI_API_KEY with new valuefirebase deploy --only functionsgit log --all -p | grep "AIzaSy"firebase functions:config:set razorpay.webhook_secret="[new]"firebase deploy --only functionsusers/{uid}/quota/current documents for unexpected tier: 'premium' entries during the exposure windowtier: 'free' via Firebase ConsoleRun before any security-relevant configuration change:
FIRESTORE RULES
☐ No new write permissions on quota, subscription, or rate limit paths
☐ No wildcard patterns overriding explicit deny rules
☐ All auth checks use request.auth != null AND uid equality
☐ Deploy sequence: rules before functions
CREDENTIALS
☐ No API keys in REACT_APP_ or NEXT_PUBLIC_ variables (except Razorpay KEY_ID)
☐ No credentials in any file that will be committed to git
☐ RAZORPAY_WEBHOOK_SECRET confirms present in Firebase Functions config
☐ Webhook handler has explicit null check for RAZORPAY_WEBHOOK_SECRET
FIREBASE AUTH
☐ Only active deployment domains in Authorized Domains list
☐ No test domains from decommissioned deployments
VERCEL ENVIRONMENT
☐ Any live API key scoped to Production only
☐ Preview environment has only test-mode credentials or none
CLIENT BUNDLE
☐ Compiled bundle does not contain Gemini API key pattern (AIzaSy)
☐ Compiled bundle contains only Razorpay KEY_ID, not KEY_SECRET