Skip to content

PII Separation Architecture

Authrim implements strict separation between Personally Identifiable Information (PII) and non-PII data at both the database and application levels. This architecture enables compliance with privacy regulations (GDPR, CCPA, APPI) while maintaining high performance for authentication flows.

Why Physical DB Separation?

Unlike traditional approaches that use table-level separation, Authrim physically separates PII into a dedicated database:

  1. Audit & Compliance: “PII is stored in a separate DB” is more convincing for GDPR/CCPA audits than table-level separation
  2. Regional Deployment: Adding an EU-only PII DB requires only a new D1 binding, no schema changes
  3. Access Control Enforcement: Code that doesn’t have PIIContext cannot access PII - visible in code review

Architecture Overview

flowchart TB
    subgraph APP["Application Layer (Hono handlers, services, flows)"]
        A1["/authorize
Core+Cache only"] A2["/token
Core+Cache only"] A3["/userinfo
Core+Cache+PII"] end subgraph REPO["Repository Layer"] R1["CoreRepository
• UserCore
• Passkey
• Session
• Role
• Relationship
• OAuthClient"] R2["PIIRepository
• UserProfile
• Identifiers
• LinkedIdentity
• AuditLog(PII)"] R3["CacheRepository
• UserCache
• ConsentCache
• RBACCache
• ClientCache"] end subgraph ADAPTER["Database Adapter Layer"] AD1["D1Adapter"] AD2["DOAdapter"] AD3["KVAdapter"] AD4["PGAdapter
(Regional)"] end subgraph STORAGE["Storage"] DB1["Global Non-PII (D1/DO)
• users_core
• passkeys
• sessions
• roles
• relationships
• oauth_clients"] DB2["Global Cache (KV/DO)
• USER_CACHE
• REBAC_CACHE
• CONSENT_CACHE
• CLIENTS_CACHE"] DB3["Regional PII (D1/Postgres)
EU: users_pii
JP: users_pii
US: users_pii
• identifiers
• linked_ids"] end APP --> REPO REPO --> ADAPTER AD1 --> DB1 AD2 --> DB1 AD3 --> DB2 AD4 --> DB3

Three-Layer Repository Pattern

Repositories are divided into three layers with distinct characteristics:

LayerCharacteristicsUse Cases
CacheRepositoryFastest, volatile, cheapUserInfo acceleration, RBAC claims caching
CoreRepositoryDurable, consistent, globalSource of truth for auth/authz
PIIRepositoryRegion-bound, GDPR compliant, encryptedPersonal data only

TypeScript-Level Access Control

TypeScript’s type system enforces PII boundaries at compile time.

Context Types

// PII-inaccessible Context (for /authorize, /token)
interface AuthContext {
core: CoreRepository;
cache: CacheRepository;
// No pii property → Cannot access PII
}
// PII-accessible Context (for /userinfo, /admin/users)
interface PIIContext extends AuthContext {
pii: PIIRepository;
}
// Handler type definitions
type AuthHandler = (c: HonoContext, ctx: AuthContext) => Promise<Response>;
type PIIHandler = (c: HonoContext, ctx: PIIContext) => Promise<Response>;

Compile-Time Enforcement

// authorize.ts - AuthHandler type, ctx.pii access causes compile error
export const authorizeHandler: AuthHandler = async (c, ctx) => {
const client = await ctx.cache.getCachedClient(clientId); // ✅ OK
const session = await ctx.core.getSession(sessionId); // ✅ OK
// ❌ Compile Error: Property 'pii' does not exist on type 'AuthContext'
// const profile = await ctx.pii.getUserProfile(userId);
return c.redirect(redirectUri);
};
// userinfo.ts - PIIHandler type, ctx.pii accessible
export const userinfoHandler: PIIHandler = async (c, ctx) => {
const core = await ctx.core.getUserCore(userId); // ✅ OK
const profile = await ctx.pii.getUserProfile(userId); // ✅ OK
return c.json({ sub: core.id, email: profile.email });
};

PII Classification

Tables Containing PII (Separation Required)

TablePII FieldsRisk Level
users_piiemail, name, phone_number, address, birthdateCritical
user_custom_fieldsfield_value (arbitrary PII possible)High
subject_identifiersidentifier_value (email, phone, DID)High
linked_identitiesprovider_email, raw_claims, profile_dataHigh
audit_log_piiip_address, user_agentMedium

Non-PII Tables (No Separation Needed)

CategoryTablesDescription
Auth Infrastructurepasskeys, sessions, password_reset_tokensPublic keys, session IDs (UUID references)
Authorization/RBACroles, user_roles, relationships, organizationsRole definitions, UUID references only
Configurationoauth_clients, upstream_providers, scope_mappingsClient settings, IdP configurations

Token PII Analysis

Authrim tokens are designed to be PII-free:

Access Token

const accessTokenClaims = {
iss: env.ISSUER_URL, // ✅ Non-PII
sub: authCodeData.sub, // ✅ UUID (Non-PII)
scope: authCodeData.scope, // ✅ Non-PII
authrim_roles: [...], // ✅ Non-PII
authrim_org_id: "...", // ✅ UUID (Non-PII)
};

Result: Access Token contains no PII. sub is a UUID, email/name are only available via UserInfo endpoint.

Database Schema

Core Database (Global D1)

CREATE TABLE users_core (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
pii_partition TEXT NOT NULL DEFAULT 'default',
pii_status TEXT NOT NULL DEFAULT 'pending', -- pending/active/failed/none/deleted
email_verified INTEGER DEFAULT 0,
user_type TEXT NOT NULL DEFAULT 'end_user',
user_version INTEGER NOT NULL DEFAULT 1,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

PII Database (Regional)

CREATE TABLE users_pii (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
email_encrypted TEXT NOT NULL,
email_blind_index TEXT NOT NULL, -- For searchable encryption
name TEXT,
phone_number_encrypted TEXT,
address_encrypted TEXT,
pii_class TEXT NOT NULL DEFAULT 'IDENTITY_CORE',
anonymized_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_users_pii_email
ON users_pii(tenant_id, email_blind_index);

Blind Index (Searchable Encryption)

To enable searching encrypted PII fields:

function createBlindIndex(value: string, masterIndexKey: string): string {
const normalized = value.toLowerCase().trim();
return crypto.createHmac('sha256', masterIndexKey)
.update(normalized)
.digest('base64url');
}
// Usage: Find user by email without exposing email in index
async function findUserByEmail(email: string): Promise<string | null> {
const blindIndex = createBlindIndex(email, env.INDEX_KEY);
const result = await piiDb.query(
'SELECT id FROM users_pii WHERE email_blind_index = ?',
[blindIndex]
);
return result?.id ?? null;
}

Region/Partition Routing

type Partition = 'default' | 'eu' | 'jp' | 'us' | string;
class PartitionRouter {
async getPIIAdapter(userId: string): Promise<DatabaseAdapter> {
// Get user's partition from Core DB
const user = await coreDb.queryOne<{ pii_partition: Partition }>(
'SELECT pii_partition FROM users_core WHERE id = ?',
[userId]
);
return this.getAdapterForPartition(user?.pii_partition || 'default');
}
getAdapterForPartition(partition: Partition): DatabaseAdapter {
// Return appropriate regional database adapter
switch (partition) {
case 'eu': return new D1Adapter(env.DB_PII_EU);
case 'jp': return new D1Adapter(env.DB_PII_JP);
default: return new D1Adapter(env.DB_PII);
}
}
}

Multi-Tenant Isolation Levels

Authrim supports three levels of tenant isolation:

LevelDescriptionUse Case
Row-levelWHERE tenant_id = ?Default, most cost-efficient
Schema-leveltenant_abc.users_piiEnterprise, separate backups
Database-levelDedicated DB instanceMaximum isolation, regional compliance
class PIIRepository {
static forTenant(tenantId: string, config: TenantConfig): PIIRepository {
switch (config.isolation_level) {
case 'database':
return new PIIRepository(getDedicatedAdapter(tenantId));
case 'schema':
return new PIIRepository(sharedDb, `tenant_${tenantId}`);
default:
return new PIIRepository(defaultDb, 'public', tenantId);
}
}
}

Circuit Breaker & Graceful Degradation

When regional PII DB fails, authentication flow continues:

export const userinfoHandler: PIIHandler = async (c, ctx) => {
// Core is required
const core = await ctx.core.getUserCore(userId);
if (!core) return c.json({ error: 'invalid_token' }, 401);
// PII via Circuit Breaker
const profile = await piiCircuitBreaker.execute(
() => ctx.pii.getUserProfile(userId),
() => Promise.resolve(null) // Fallback: return null
);
// Degraded response if PII unavailable
return c.json({
sub: core.id,
email: profile?.email ?? null,
name: profile?.name ?? null,
_degraded: profile === null, // Notify client of degraded state
});
};

GDPR Compliance

Soft Delete + Anonymization

async function deleteUser(userId: string, mode: 'hard_delete' | 'anonymize') {
// 1. Invalidate cache immediately
await cache.invalidateUser(userId);
// 2. Process PII
if (mode === 'hard_delete') {
await pii.deleteUserProfile(userId);
} else {
await pii.anonymizeUserProfile(userId);
// email → "deleted_{userId}@anonymized.local"
// name, phone, address → NULL
}
// 3. Soft delete in Core (keep for audit)
await core.updateUserCore(userId, {
is_deleted: true,
deleted_at: Date.now()
});
// 4. Delete relationships, sessions, passkeys
await core.deleteUserRelationships(userId);
await core.deleteUserSessions(userId);
}

Tombstone Table

CREATE TABLE users_pii_tombstone (
user_id TEXT PRIMARY KEY,
email_blind_index TEXT, -- Prevent re-registration
deleted_by TEXT NOT NULL,
deletion_reason TEXT,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
retention_until TIMESTAMPTZ NOT NULL -- When to purge
);

Benefits

1. Regulatory Compliance

  • GDPR: Easy DSAR and right to erasure implementation
  • CCPA: Clear separation for “Do Not Sell” requirements
  • Data Residency: PII database can be deployed in specific regions

2. Security

  • Reduced Attack Surface: Core authentication services don’t have PII access
  • Principle of Least Privilege: Only PII-specific endpoints access personal data
  • Audit Trail: All PII access is logged separately

3. Performance

  • Smaller Core Database: Faster queries for authentication/authorization
  • Caching Safety: Core data can be cached aggressively globally
  • No PII in Tokens: Tokens remain small and secure

Best Practices

  1. Never JOIN across databases - Fetch from each database separately using Promise.all
  2. Use user ID as the link - The only shared field between databases
  3. Log all PII access - Implement audit logging for all PII operations
  4. Use pii_status state machine - Track PII write status (pending/active/failed/none)
  5. Prefer partition over region - More flexible routing (tenant, plan, security level)
  6. Default IP routing OFF - VPN/proxy makes IP unreliable for compliance