@nick-skriabin/relic
v0.1.11
Published
Rails-credentials-like encrypted secrets for JS apps - works in Node and Edge runtimes
Maintainers
Readme
Table of Contents
- Why Relic?
- Installation
- Quick Start
- CLI
- API
- Edge Runtimes
- Error Handling
- Security
- Integrations
- Comparison
- Troubleshooting
- TypeScript
Why Relic?
Managing secrets in JavaScript applications is painful. Environment variables scatter across .env files, CI configs, and deployment dashboards. Relic takes a different approach:

One encrypted file. One master key. Git-friendly diffs. Works everywhere.
Features
| Feature | Description |
|---------|-------------|
| 🔐 Strong Encryption | AES-256-GCM with PBKDF2 key derivation (600k iterations) |
| 🌐 Edge Compatible | Web Crypto API only — runs on Cloudflare Workers, Vercel Edge, Deno Deploy |
| 📦 Single Artifact | One JSON file containing all your secrets, safe to commit |
| 📝 Git-Friendly | Per-value encryption keeps keys visible — meaningful diffs and easy merges |
| 🛠️ Familiar Workflow | Edit secrets with $EDITOR, just like Rails credentials |
| ✅ Tamper-Proof | Authenticated encryption detects any modification |
| 🪶 Zero Dependencies | ~7KB runtime, no external packages |
Installation
# npm
npm install @nick-skriabin/relic
# pnpm
pnpm add @nick-skriabin/relic
# yarn
yarn add @nick-skriabin/relic
# bun
bun add @nick-skriabin/relicQuick Start
1. Initialize Relic
npx relic initThis generates a master key and saves it to config/relic.key (automatically added to .gitignore, along with config/relic.local.json).
2. Create Your Secrets
npx relic editYour editor opens with an empty JSON object. Add your secrets:
{
"DATABASE_URL": "postgres://user:pass@host:5432/db",
"API_KEY": "sk_live_xxxxxxxxxxxxx",
"STRIPE_SECRET": "sk_live_xxxxxxxxxxxxx",
"JWT_SECRET": "your-jwt-secret-here"
}Save and close. Relic encrypts and writes to config/relic.enc.
3. Use in Your App
import { createRelic } from "@nick-skriabin/relic";
const relic = createRelic();
// Load all secrets
const secrets = await relic.load();
console.log(secrets.DATABASE_URL);
// Or get individual values
const apiKey = await relic.get("API_KEY");4. Deploy
Set two environment variables in production:
| Variable | Value |
|----------|-------|
| RELIC_MASTER_KEY | Your master key (from config/relic.key) |
| RELIC_ARTIFACT | Contents of config/relic.enc |
Vercel (Node.js runtime)
Since the artifact is committed to your repo, Relic can read it directly. You only need to set RELIC_MASTER_KEY:
Add
RELIC_MASTER_KEYto Vercel Environment Variables (Settings → Environment Variables)cat config/relic.key # Copy this valueUse
artifactPathto read from the committed file:// lib/relic.ts import { createRelic } from "@nick-skriabin/relic"; export const relic = createRelic({ artifactPath: "./config/relic.enc", });Use anywhere in your app:
import { relic } from "@/lib/relic"; const secrets = await relic.load();
Vercel Edge Runtime
Edge functions don't have filesystem access. Bundle the artifact at build time instead.
Next.js setup:
Configure webpack to handle
.encfiles innext.config.js:/** @type {import('next').NextConfig} */ const nextConfig = { webpack: (config) => { config.module.rules.push({ test: /\.enc$/, type: "asset/source", }); return config; }, }; module.exports = nextConfig;Add type declaration (optional, for TypeScript):
// types/assets.d.ts declare module "*.enc" { const content: string; export default content; }Import and use:
// lib/relic.ts import { createRelic } from "@nick-skriabin/relic"; import artifact from "../config/relic.enc"; export const relic = createRelic({ artifact, });
Vite setup:
Vite supports raw imports natively:
import { createRelic } from "@nick-skriabin/relic";
import artifact from "./config/relic.enc?raw";
export const relic = createRelic({
artifact,
});Cloudflare Workers
# Using wrangler secrets
wrangler secret put RELIC_MASTER_KEY
wrangler secret put RELIC_ARTIFACT
# Or in wrangler.toml (not recommended for secrets)
[vars]
RELIC_ARTIFACT = "..."Other Platforms
# Generic - export from local files
export RELIC_MASTER_KEY=$(cat config/relic.key)
export RELIC_ARTIFACT=$(cat config/relic.enc)
# Docker
docker run -e RELIC_MASTER_KEY="$(cat config/relic.key)" \
-e RELIC_ARTIFACT="$(cat config/relic.enc)" \
your-image
# GitHub Actions (add as repository secrets, then reference)
env:
RELIC_MASTER_KEY: ${{ secrets.RELIC_MASTER_KEY }}
RELIC_ARTIFACT: ${{ secrets.RELIC_ARTIFACT }}CLI
The CLI is the only way to modify secrets. This ensures secrets are always properly encrypted.
Commands
# Initialize relic (generates key file)
relic init
# Edit secrets (creates file if it doesn't exist)
relic edit
# Edit a specific file
relic edit --file ./secrets/production.enc
# Rotate master key (decrypt, generate new key, re-encrypt)
relic rotate
# List all secret keys (without values)
relic --print-keys
# Show help
relic --helpLocal Development Setup
The init command sets up relic for local development:
relic initThis:
- Generates a secure random master key
- Saves it to
config/relic.key - Adds
config/relic.keyandconfig/relic.local.jsonto.gitignore
Now you can use relic edit without setting environment variables.
Key Resolution Order
Relic looks for the master key in this order:
- Key file (
config/relic.key) — for local development - Environment variable (
RELIC_MASTER_KEY) — for CI/production
Local Development Production/CI
───────────────── ─────────────
config/relic.key → RELIC_MASTER_KEY env var
(auto-generated) (from secrets manager)Editor Configuration
Relic uses your preferred editor:
# Uses these in order of preference:
# 1. $RELIC_EDITOR (for automation/CI)
# 2. $EDITOR
# 3. vi (Unix) / notepad (Windows)
export EDITOR=vim # Use Vim
export EDITOR="code --wait" # Use VS Code
export EDITOR="subl --wait" # Use Sublime Text
export EDITOR="nano" # Use NanoDefault File Locations
your-project/
├── config/
│ ├── relic.key ← Master key (git-ignored)
│ ├── relic.enc ← Encrypted secrets (commit this)
│ ├── relic.d.ts ← Auto-generated types (commit this)
│ └── relic.local.json ← Local overrides (git-ignored, optional)
├── src/
└── package.jsonOverride with --file and --key-file:
relic init --key-file ./secrets/master.key
relic edit --file ./secrets/production.enc --key-file ./secrets/master.keyLocal Overrides
For local development, you can override encrypted secrets with a plain JSON file. This is useful when you need different values locally without modifying the shared encrypted artifact.
Create config/relic.local.json (add to .gitignore):
{
"DATABASE_URL": "postgres://localhost:5432/myapp_dev",
"DEBUG": true
}Values in relic.local.json are merged on top of encrypted secrets:
// config/relic.enc contains: { "DATABASE_URL": "prod-url", "API_KEY": "secret" }
// config/relic.local.json contains: { "DATABASE_URL": "localhost", "DEBUG": true }
const secrets = await relic.load();
// Result: { "DATABASE_URL": "localhost", "API_KEY": "secret", "DEBUG": true }Resolution order:
- Load and decrypt
config/relic.enc - Deep merge
config/relic.local.jsonon top (if present) - Local values override encrypted values
To disable local overrides:
const relic = createRelic({ localOverrides: false });To use a custom path:
const relic = createRelic({ localOverrides: "./secrets/local.json" });Public Secrets (Frontend-Safe)
Relic supports exposing certain secrets to the frontend via a special public key. This works similarly to NEXT_PUBLIC_ environment variables in Next.js.
Structure your secrets with a public key:
{
"DATABASE_URL": "postgres://secret-host/db",
"API_KEY": "sk_live_secret",
"public": {
"API_URL": "https://api.example.com",
"APP_NAME": "My App",
"STRIPE_PUBLIC_KEY": "pk_live_..."
}
}Use loadPublic() to get only the public secrets:
// Server-side: access everything
const secrets = await relic.load();
secrets.DATABASE_URL; // ✓ Available
secrets.public.API_URL; // ✓ Available
// Frontend-safe: only public secrets
const publicSecrets = await relic.loadPublic();
// => { API_URL: "...", APP_NAME: "...", STRIPE_PUBLIC_KEY: "..." }Next.js example:
// app/layout.tsx (Server Component)
import { relic } from "@/lib/relic";
export default async function RootLayout({ children }) {
const publicSecrets = await relic.loadPublic();
return (
<html>
<body>
<script
dangerouslySetInnerHTML={{
__html: `window.__PUBLIC_CONFIG__ = ${JSON.stringify(publicSecrets)}`,
}}
/>
{children}
</body>
</html>
);
}API
createRelic(options?)
Creates a Relic instance for accessing secrets.
import { createRelic } from "@nick-skriabin/relic";
const relic = createRelic({
// Read artifact from file (Node.js only)
artifactPath: "./config/relic.enc",
// Or provide artifact directly (for Edge/bundling)
artifact: "...",
// Or specify env var name (default: "RELIC_ARTIFACT")
artifactEnv: "MY_SECRETS",
// Provide master key directly
masterKey: "...",
// Or specify env var name (default: "RELIC_MASTER_KEY")
masterKeyEnv: "MY_MASTER_KEY",
// Cache decrypted secrets (default: true)
cache: true,
// Local overrides file path, or false to disable
// (default: "config/relic.local.json")
localOverrides: "./config/relic.local.json",
});Instance Methods
// Load all secrets as an object
const secrets = await relic.load();
// => { API_KEY: "...", DATABASE_URL: "...", public: { ... } }
// Load only public secrets (safe for frontend)
const publicSecrets = await relic.loadPublic();
// => { API_URL: "...", APP_NAME: "..." }
// Get a specific secret (throws if not found)
const apiKey = await relic.get("API_KEY");
// Check if a secret exists
if (await relic.has("OPTIONAL_KEY")) {
// ...
}
// List all available keys
const keys = await relic.keys();
// => ["API_KEY", "DATABASE_URL", "public", ...]Low-Level Functions
For advanced use cases, you can use the encryption functions directly:
import { encryptPayload, decryptPayload } from "@nick-skriabin/relic";
// Encrypt
const artifact = await encryptPayload(
"my-master-key",
JSON.stringify({ secret: "value" })
);
// Decrypt
const plaintext = await decryptPayload("my-master-key", artifact);
const data = JSON.parse(plaintext);Edge Runtimes
Relic is designed from the ground up to work in Edge environments where Node.js APIs aren't available.
Since Edge runtimes don't have filesystem access, you need to bundle the artifact at build time. This keeps your secrets in the committed file (not in env vars) while still working in Edge.
Next.js (Node.js Runtime)
No bundler configuration needed — Relic reads config/relic.enc automatically:
// lib/relic.ts
import { createRelic } from "@nick-skriabin/relic";
export const relic = createRelic();// app/api/route.ts
import { relic } from "@/lib/relic";
export async function GET() {
const secrets = await relic.load();
return Response.json({ ok: true });
}Next.js (Edge Runtime & Middleware)
Edge functions don't have filesystem access, so the artifact must be bundled.
Next.js 15+ (Turbopack):
// next.config.ts
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.enc": {
loaders: ["raw-loader"],
as: "*.js",
},
},
},
};
export default nextConfig;Next.js 14 and below (Webpack):
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.module.rules.push({
test: /\.enc$/,
type: "asset/source",
});
return config;
},
};
module.exports = nextConfig;Usage (same for both):
Add a type declaration (optional, for TypeScript):
// types/assets.d.ts declare module "*.enc" { const content: string; export default content; }Create a shared relic instance:
// lib/relic.ts import { createRelic } from "@nick-skriabin/relic"; import artifact from "../config/relic.enc"; export const relic = createRelic({ artifact });Use in Edge API routes:
// app/api/route.ts import { relic } from "@/lib/relic"; export const runtime = "edge"; export async function GET() { const secrets = await relic.load(); return Response.json({ ok: true }); }Use in Middleware:
// middleware.ts import { relic } from "@/lib/relic"; export async function middleware(request: Request) { const { API_KEY } = await relic.load(); // Validate requests, add headers, etc. } export const config = { matcher: "/api/:path*", };
Cloudflare Workers
// src/worker.ts
import { createRelic } from "@nick-skriabin/relic";
// Bundled at build time (esbuild/wrangler handles this)
import artifact from "../config/relic.enc";
const relic = createRelic({
artifact,
masterKey: RELIC_MASTER_KEY, // from wrangler secret
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { API_KEY } = await relic.load();
return new Response("OK");
},
};Configure wrangler to handle .enc files:
# wrangler.toml
[rules]
{ type = "Text", globs = ["**/*.enc"] }Set the master key as a secret:
wrangler secret put RELIC_MASTER_KEYVite / Nuxt / SvelteKit
Vite supports raw imports natively:
import { createRelic } from "@nick-skriabin/relic";
import artifact from "./config/relic.enc?raw";
export const relic = createRelic({ artifact });Deno / Deno Deploy
import { createRelic } from "npm:@nick-skriabin/relic";
// Deno supports reading files at module load time
const artifact = await Deno.readTextFile("./config/relic.enc");
const relic = createRelic({ artifact });
Deno.serve(async () => {
const secrets = await relic.load();
return new Response("OK");
});Error Handling
Relic throws RelicError with specific error codes for easy handling:
import { createRelic, RelicError, ErrorCodes } from "@nick-skriabin/relic";
const relic = createRelic();
try {
const secrets = await relic.load();
} catch (error) {
if (error instanceof RelicError) {
switch (error.code) {
case ErrorCodes.MISSING_ARTIFACT:
console.error("No artifact provided. Set RELIC_ARTIFACT env var.");
break;
case ErrorCodes.MISSING_MASTER_KEY:
console.error("No master key. Set RELIC_MASTER_KEY env var.");
break;
case ErrorCodes.DECRYPT_FAILED:
console.error("Wrong master key or corrupted artifact.");
break;
case ErrorCodes.INVALID_JSON:
console.error("Artifact doesn't contain valid JSON.");
break;
case ErrorCodes.UNSUPPORTED_VERSION:
console.error("Artifact version not supported. Update relic.");
break;
case ErrorCodes.KEY_NOT_FOUND:
console.error(`Secret key not found: ${error.message}`);
break;
}
}
throw error;
}Error Codes Reference
| Code | Meaning |
|------|---------|
| RELIC_ERR_MISSING_ARTIFACT | No artifact provided and env var not set |
| RELIC_ERR_MISSING_MASTER_KEY | No master key provided and env var not set |
| RELIC_ERR_DECRYPT_FAILED | Decryption failed (wrong key or tampered data) |
| RELIC_ERR_INVALID_JSON | Decrypted content is not valid JSON |
| RELIC_ERR_UNSUPPORTED_VERSION | Artifact uses an unsupported format version |
| RELIC_ERR_KEY_NOT_FOUND | Requested secret key doesn't exist |
| RELIC_ERR_INVALID_ARTIFACT_FORMAT | Artifact structure is malformed |
Security
Cryptographic Details
| Component | Specification | |-----------|---------------| | Cipher | AES-256-GCM | | Key Derivation | PBKDF2-SHA256, 600,000 iterations | | Salt | 16 bytes, randomly generated | | IV/Nonce | 12 bytes, randomly generated | | Auth Tag | 128 bits (included in ciphertext) |
Artifact Format
Relic uses per-value encryption — your JSON structure remains visible, only values are encrypted:
{
"API_KEY": "relic:v1:base64(iterations + salt + iv + ciphertext)...",
"DATABASE_URL": "relic:v1:base64(iterations + salt + iv + ciphertext)...",
"nested": {
"SECRET": "relic:v1:base64(iterations + salt + iv + ciphertext)..."
}
}This format has several advantages:
- Meaningful git diffs — see which keys changed, not just "binary file modified"
- Easy merge conflicts — resolve conflicts by key, not by re-encrypting everything
- Visible structure — know what secrets exist without decrypting
- Nested support — organize secrets with nested objects
Best Practices
✅ Do
- Commit the encrypted artifact — it's safe and enables GitOps workflows
- Store the master key in a secrets manager — AWS Secrets Manager, HashiCorp Vault, 1Password, etc.
- Use different master keys per environment —
staging.encwith staging key,production.encwith production key - Back up your master key — losing it means losing access to all secrets
- Rotate secrets regularly — edit the artifact, re-encrypt with same key
❌ Don't
- Never commit the master key — not in
.env, not in code, not anywhere in git - Never log secrets — Relic errors never include secret values
- Never share master keys — each developer can have a dev artifact with a dev key
- Never reuse master keys — each project and environment should have unique keys
Key Rotation
To rotate the master key:
npx relic rotateThis command will:
- Decrypt the artifact with the current key
- Generate a new master key and save it to
config/relic.key - Re-encrypt the artifact with the new key
After rotation, update the master key in all deployment environments:
export RELIC_MASTER_KEY=$(cat config/relic.key)Threat Model
Relic protects against:
- ✅ Secrets exposed in git history
- ✅ Secrets leaked in logs/errors
- ✅ Unauthorized access without master key
- ✅ Tampering detection (modified artifacts fail decryption)
Relic does NOT protect against:
- ❌ Master key compromise
- ❌ Memory inspection on running processes
- ❌ Compromised deployment environment
Integrations
React
Relic provides a React integration for passing public secrets to client components via context.
# Install (react is a peer dependency)
npm install @nick-skriabin/relic reactServer Component (Next.js):
// app/layout.tsx
import { relic } from "@/lib/relic";
import { RelicProvider } from "@nick-skriabin/relic/react";
export default async function RootLayout({ children }) {
const secrets = await relic.loadPublic();
return (
<html>
<body>
<RelicProvider secrets={secrets}>{children}</RelicProvider>
</body>
</html>
);
}Client Component:
"use client";
import { useRelic } from "@nick-skriabin/relic/react";
function MyComponent() {
// Get all public secrets
const secrets = useRelic();
// Or get a single key (throws if missing)
const apiUrl = useRelic("API_URL");
return <div>{apiUrl}</div>;
}useRelic() throws if used outside of <RelicProvider>. useRelic(key) throws if the key doesn't exist in the provided secrets.
Comparison
| Feature | Relic | dotenv | Rails Credentials | SOPS | |---------|-------|--------|-------------------|------| | Encrypted at rest | ✅ | ❌ | ✅ | ✅ | | Edge runtime support | ✅ | ✅ | ❌ | ❌ | | Git-friendly diffs | ✅ | ✅ | ❌ | ✅ | | Single file | ✅ | ❌ | ✅ | ✅ | | No external dependencies | ✅ | ✅ | ❌ | ❌ | | Key management | Manual | N/A | Manual | KMS/PGP | | Edit with $EDITOR | ✅ | ❌ | ✅ | ✅ |
Troubleshooting
"RELIC_ERR_MISSING_MASTER_KEY"
The master key isn't set. Make sure RELIC_MASTER_KEY is in your environment:
echo $RELIC_MASTER_KEY # Should print your key"RELIC_ERR_DECRYPT_FAILED"
Either the master key is wrong, or the artifact was corrupted/tampered with.
# Verify you're using the correct key
# The key must be EXACTLY the same — including any trailing whitespace
# Check for copy-paste issues
echo -n "$RELIC_MASTER_KEY" | xxd # Inspect raw bytes"Editor exits immediately"
Some editors need a flag to wait for the file to be closed:
export EDITOR="code --wait" # VS Code
export EDITOR="subl --wait" # Sublime Text
export EDITOR="atom --wait" # Atom"Can't use in browser"
Relic is designed for server-side use only. Secrets should never be sent to the browser.
TypeScript
Auto-Generated Types
Every time you run relic edit, Relic generates a config/relic.d.ts file with TypeScript interfaces matching your secrets structure:
config/
├── relic.enc ← Encrypted secrets
├── relic.key ← Master key (git-ignored)
├── relic.local.json ← Local overrides (git-ignored)
└── relic.d.ts ← Auto-generated types (commit this)For this secrets file:
{
"DATABASE_URL": "postgres://localhost/db",
"API_KEY": "sk_live_xxx",
"aws": {
"ACCESS_KEY": "AKIA...",
"REGION": "us-east-1"
},
"public": {
"API_URL": "https://api.example.com"
}
}Relic generates:
// config/relic.d.ts (auto-generated)
export interface RelicSecrets {
DATABASE_URL: string;
API_KEY: string;
aws: {
ACCESS_KEY: string;
REGION: string;
};
public: {
API_URL: string;
};
}
export interface RelicPublicSecrets {
API_URL: string;
}
// Module augmentation — makes load() and loadPublic() return typed results
declare module "@nick-skriabin/relic" {
interface RelicInstance {
load(): Promise<RelicSecrets>;
loadPublic(): Promise<RelicPublicSecrets>;
}
}If your tsconfig.json includes config/ (most do by default), the types are picked up automatically. Otherwise, add it:
{
"include": ["src", "config/relic.d.ts"]
}Now load() and loadPublic() return typed results automatically — no casting needed:
import { createRelic } from "@nick-skriabin/relic";
const relic = createRelic();
const secrets = await relic.load();
secrets.DATABASE_URL; // string
secrets.aws.ACCESS_KEY; // string — deep nesting supported
secrets.NONEXISTENT; // TS error
const pub = await relic.loadPublic();
pub.API_URL; // stringLicense
MIT © 2025
