Back to KB
Difficulty
Intermediate
Read Time
8 min

Credentials in web applications: how to store them properly

By Codcompass Team··8 min read

Beyond the .env File: A Practical Architecture for Application Secrets

Current Situation Analysis

Credential exposure remains the single most frequent root cause of application breaches. Industry breach reports consistently show that compromised secrets account for the majority of successful intrusions, yet development teams continue to treat credentials as interchangeable data blobs. The fundamental misunderstanding lies in assuming that "security" means applying a single protective layer across all sensitive values. In reality, passwords, session tokens, and service keys operate under completely different threat models, lifecycle requirements, and cryptographic constraints.

This problem persists because modern frameworks abstract away authentication mechanics, leading developers to copy-paste patterns without understanding the underlying security properties. A session token stored in browser storage, a database password baked into a container image, and a user password encrypted with AES-256 all share one fatal flaw: they violate the principle of cryptographic separation. When an attacker gains read access to a database, a client-side script, or a build artifact, the damage scales directly with how poorly these categories were isolated.

The technical reality is unforgiving. Fast cryptographic hashes like SHA-256 can be computed billions of times per second on consumer GPUs, reducing password cracking to a trivial brute-force exercise. Client-side storage mechanisms like localStorage are directly accessible to any JavaScript execution context, making them trivial targets for cross-site scripting (XSS) payloads. Build-time secret injection leaves historical layers in container registries that can be extracted by anyone with pull access. Treating these distinct vectors with a uniform approach guarantees eventual compromise.

WOW Moment: Key Findings

The architectural divergence between credential types is not theoretical; it dictates your entire security posture. Misaligning storage, validation, and lifecycle management across these categories creates immediate attack surfaces.

Credential CategoryStorage MechanismRevocation ModelCryptographic Property
User PasswordsOne-way hash (Argon2id/bcrypt)Password reset flowMemory-hard, salted, irreversible
Session TokensServer-side store + HttpOnly cookieServer-side deletionHigh-entropy opaque string
Service SecretsRuntime injection / Secret ManagerImmediate rotationSymmetric/Asymmetric key material

This comparison reveals why a unified approach fails. Passwords require irreversible transformation to survive database leaks. Sessions require server-controlled state to enable instant revocation. Service secrets require runtime isolation to survive build pipeline or container exposure. When you force one category into another's storage pattern, you sacrifice either revocation capability, cryptographic safety, or deployment security. Recognizing these boundaries enables you to design systems that contain breaches rather than amplify them.

Core Solution

Building a resilient credential architecture requires separating concerns at the framework level, enforcing cryptographic boundaries, and injecting secrets at runtime. The implementation below demonstrates a TypeScript/Node.js backend that enforces these principles across all three categories.

1. User Passwords: Memory-Hard Hashing with Automatic Salting

Passwords must never be stored in recoverable form. The correct approach uses a memory-hard, key-derivation function that intentionally slows down verification to thwart brute-force attacks. Modern libraries handle salt generation and parameter tuning automatically.

import { hash, verify } from '@node-rs/argon2';

export class PasswordVault {
  static async createHash(plaintext: string): Promise<string> {
    return hash(plaintext, {
      memoryCost: 19456,
      timeCost: 2,
      parallelism: 1,
      type: 2 // Argon2id
    });
  }

  static async validate(plaintext: string, storedHash: string): Promise<boolean> {
    return verify(storedHash, plaintext);
  }
}

Architecture Rationale: Argon2id is selected over bcrypt or scrypt because it resists both GPU-based parallel attacks and side-channel timing leaks. The memoryCost parameter forces the hashing operation to consume significant RAM, making cloud-based cracking economically unviable. Salts are embedded in the output string, eliminating manual salt management. Verification remains constant-time, preventing timing attacks.

Sessions should never carry payload data. Instead, generate a high-entropy opaque identifier, store it server-side, and deliver it via a strictly configured cookie. This enables instant revocation and eliminates client-side parsing vulnerabilities.

import { randomBytes } from 'crypto';
import type { Request, Response } from 'express';

export class SessionManager {
  static generateToken(): string {
    return randomBytes(32).toString('hex'); // 256 bits of entropy
  }

  static attachSecureCookie(res: Response, token: string, ttlMs: number): void {
    res.cookie('app_sid', token, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: ttlMs,
      path: '/'
    });
  }

  static async validateSession(token: string, store: Map<string, unknown>): Promise<boolean> {
    return store.has(token);
  }
}

Architecture Rationale: HttpOnly blocks JavaScript access, neutralizing XSS token theft. Secure enforces TLS-only transmission. SameSite=Lax mitigates cross-site request forgery while preserving standard navigation flows. Storing the token in a server-side map (or Redis in production) allows immediate invalidation by deleting the

key. This contrasts sharply with self-contained tokens like JWTs, which remain valid until expiration and cannot be revoked without maintaining a server-side denylist.

3. Service Credentials: Runtime Injection with Startup Validation

Service secrets must never exist in source control, build artifacts, or client bundles. They should be injected by the orchestration layer and validated at process startup. Missing credentials should cause immediate failure rather than silent degradation.

export interface ServiceConfig {
  databasePassword: string;
  paymentProviderKey: string;
  signingPrivateKey: string;
}

export class ConfigLoader {
  static load(): ServiceConfig {
    const required = [
      'DB_PRIMARY_PASSWORD',
      'PAYMENT_API_SECRET',
      'JWT_SIGNING_KEY'
    ];

    const missing = required.filter(key => !process.env[key]);
    if (missing.length > 0) {
      throw new Error(`Missing runtime secrets: ${missing.join(', ')}`);
    }

    return {
      databasePassword: process.env.DB_PRIMARY_PASSWORD!,
      paymentProviderKey: process.env.PAYMENT_API_SECRET!,
      signingPrivateKey: process.env.JWT_SIGNING_KEY!
    };
  }
}

Architecture Rationale: Failing fast at startup prevents partially initialized services that might leak credentials through error paths or fallback logic. Environment variables are injected by the container orchestrator or secret manager, ensuring they never touch the filesystem during build. This pattern scales to dedicated secret managers (HashiCorp Vault, AWS Secrets Manager) where credentials are fetched dynamically and rotated without redeployment.

Pitfall Guide

1. The Reversible Encryption Fallacy

Explanation: Developers sometimes encrypt passwords with AES or similar symmetric algorithms to "recover" them for users. This creates a single point of failure: if the encryption key is compromised, every password is instantly exposed. Fix: Never implement password recovery. Use time-limited, single-use reset tokens delivered via email. Store only irreversible hashes.

2. Client-Side Token Storage

Explanation: Storing session identifiers or JWTs in localStorage or sessionStorage exposes them to any script running on the page. A single XSS vulnerability grants full account takeover. Fix: Deliver tokens exclusively via HttpOnly cookies. If client-side frameworks require token access, hold them in volatile memory and never persist across reloads.

3. Build-Time Secret Injection

Explanation: Passing secrets as Docker build arguments or baking them into configuration files during CI leaves them in image layers. Anyone with registry read access can extract historical layers and recover the values. Fix: Use multi-stage builds that exclude secrets entirely. Inject credentials at runtime through orchestration platforms or secret managers.

4. Static CI/CD Access Keys

Explanation: Long-lived cloud access keys stored in pipeline variables remain valid indefinitely. If a runner is compromised or a workflow logs output, the key is permanently exposed. Fix: Implement OIDC federation between your CI provider and cloud IAM. Workflows receive short-lived, scoped tokens that expire immediately after execution.

5. Fast Hash Substitution

Explanation: Using SHA-256, MD5, or custom salting schemes for passwords assumes that adding randomness fixes the speed problem. Modern hardware still computes these at billions of operations per second. Fix: Delegate to vetted libraries (@node-rs/argon2, bcrypt, scrypt). These enforce memory-hardness and automatic per-password salting.

6. Over-Privileged Service Accounts

Explanation: Using a single database or API key across all environments and services violates least privilege. A breach in staging immediately compromises production. Fix: Create isolated credentials per environment and service. Apply IAM policies that restrict access to specific resources, actions, and IP ranges.

7. Unredacted Structured Logging

Explanation: Debug middleware or error handlers often serialize entire request bodies or configuration objects, inadvertently writing passwords or API keys to log aggregation systems. Fix: Implement a logging middleware that strips or masks known sensitive fields (password, secret, token, key) before serialization. Use allowlists for logged properties.

Production Bundle

Action Checklist

  • Audit all authentication endpoints to confirm passwords are hashed with Argon2id or bcrypt before database insertion
  • Verify session cookies include HttpOnly, Secure, and SameSite flags in all deployment environments
  • Replace all localStorage token storage with HttpOnly cookie delivery or in-memory state management
  • Remove all secrets from Dockerfiles, CI pipelines, and configuration templates; enforce runtime injection
  • Configure CI/CD to use OIDC federation instead of static cloud access keys
  • Implement startup validation that crashes the process if required service secrets are missing
  • Deploy structured logging redaction middleware to prevent credential leakage in observability pipelines
  • Schedule automated credential rotation for service keys and verify application handles key transitions gracefully

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
High-traffic web app with stateful backendServer-side sessions + Redis storeEnables instant revocation, scales horizontally, avoids JWT complexityLow infrastructure cost, moderate operational overhead
Mobile app or third-party API integrationJWT with short expiry + refresh rotationStateless validation reduces backend load, fits disconnected architecturesHigher risk if leaked, requires strict expiry management
Small team, single cloud providerEnvironment variables + platform secret storeSimple setup, native integration, sufficient for low-risk workloadsMinimal, scales poorly with compliance requirements
Enterprise, multi-cloud, compliance-drivenHashiCorp Vault or AWS Secrets ManagerCentralized rotation, audit trails, dynamic credentials, policy enforcementHigher operational cost, mandatory for SOC2/ISO27001
CI/CD pipeline with cloud deploymentsOIDC federation + short-lived tokensEliminates static keys, automatic expiration, scoped permissionsInitial setup complexity, long-term security ROI

Configuration Template

# Multi-stage build: secrets never enter the image
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

# Runtime secrets injected by orchestrator
# DO NOT use ENV or ARG for credentials
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
// Kubernetes Secret + Deployment pattern (YAML)
apiVersion: v1
kind: Secret
metadata:
  name: app-runtime-secrets
type: Opaque
data:
  db-password: <base64-encoded-password>
  api-key: <base64-encoded-key>
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-service
spec:
  template:
    spec:
      containers:
      - name: app
        image: registry/web-service:latest
        env:
        - name: DB_PRIMARY_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-runtime-secrets
              key: db-password
        - name: PAYMENT_API_SECRET
          valueFrom:
            secretKeyRef:
              name: app-runtime-secrets
              key: api-key

Quick Start Guide

  1. Install cryptographic dependencies: Run npm install @node-rs/argon2 and npm install express cookie-parser to establish the foundation for secure hashing and cookie management.
  2. Implement the config loader: Create a ConfigLoader class that reads required environment variables at startup and throws if any are missing. Integrate it into your application bootstrap sequence.
  3. Configure session middleware: Set up cookie parsing with HttpOnly, Secure, and SameSite=Lax flags. Generate session tokens using crypto.randomBytes(32) and store them in a server-side map or Redis instance.
  4. Replace password storage: Update your user creation and login endpoints to use PasswordVault.createHash() and PasswordVault.validate(). Remove any existing password columns that store plaintext or reversible encryption.
  5. Validate in staging: Deploy to a staging environment with mock secrets injected via your platform's configuration dashboard. Verify that the application fails fast if secrets are omitted, and confirm that session cookies carry the correct security flags using browser developer tools.