Implementing a Post Encryption System for Your Astro Blog

This guide will walk you through implementing a secure post encryption system for your Astro blog using a “Build-time Encryption + Client-side Decryption” approach. This allows you to protect private content without needing a backend server.

Core Principles

Workflow

  1. Build-time: Astro detects a password field in the post’s frontmatter and uses the AES-256 algorithm to encrypt the raw Markdown content.
  2. Runtime: When a visitor accesses the page, the browser only receives the encrypted ciphertext.
  3. Decryption: After the visitor enters the correct password, client-side JavaScript decrypts and renders the content.

Security

  • Content is encrypted with AES-256 at build time.
  • Viewing the page source (F12) reveals only ciphertext.
  • A “magic prefix” __P_VALID__ is used to verify password correctness.
  • Content is mathematically impossible to retrieve without the key.

Implementation Steps

Step 1: Install Dependencies

Install the encryption library and a Markdown rendering library:

npm install crypto-js marked

Roles of these libraries:

  • crypto-js: Used for AES encryption and decryption.
  • marked: Used to render decrypted Markdown into HTML on the client side.

Step 2: Update Content Schema

Add the password field to your posts schema in src/content.config.ts:

import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";

const posts = defineCollection({
  loader: glob({
    pattern: "**/*.md",
    base: "src/contents/posts",
  }),
  schema: z.object({
    title: z.string(),
    published: z.date(),
    draft: z.boolean().optional(),
    description: z.string().optional(),
    cover: z.string().optional(),
    tags: z.array(z.string()).optional(),
    category: z.string().optional(),
    author: z.string().optional(),
    sourceLink: z.string().optional(),
    licenseName: z.string().optional(),
    licenseUrl: z.string().optional(),
    password: z.string().optional(), // Added: Password field
  }),
});

const specs = defineCollection({
  loader: glob({
    pattern: "**/*.md",
    base: "src/contents/specs",
  }),
});

export const collections = { posts, specs };

Step 3: Create the Decryption Component

Create the file src/components/PostProtector.svelte:

<script lang="ts">
    import CryptoJS from "crypto-js";
    import { marked } from "marked";

    interface Props {
        encryptedContent: string;
    }

    let { encryptedContent }: Props = $props();
    let password = $state("");
    let decryptedHtml = $state<string | null>(null);
    let error = $state(false);

    function handleDecrypt(e: Event) {
        e.preventDefault();
        try {
            // Attempt decryption
            const bytes = CryptoJS.AES.decrypt(encryptedContent, password);
            const originalText = bytes.toString(CryptoJS.enc.Utf8);

            // Verify result: check for the magic prefix
            if (originalText.startsWith("__P_VALID__")) {
                // Remove prefix to get real Markdown content
                const markdownContent = originalText.replace("__P_VALID__", "");
                // Render Markdown to HTML
                decryptedHtml = marked.parse(markdownContent) as string;
                error = false;
            } else {
                throw new Error("Password incorrect");
            }
        } catch (err) {
            error = true;
            decryptedHtml = null;
        }
    }
</script>

{#if decryptedHtml}
    <!-- Decryption successful, render HTML content -->
    <div
        class="article prose prose-base dark:prose-invert shrink max-w-full decrypted-content"
        data-pagefind-body
    >
        {@html decryptedHtml}
    </div>
{:else}
    <!-- Password entry interface -->
    <div class="password-container">
        <div class="lock-icon">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
                <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
            </svg>
        </div>
        <h2 class="password-title">
            Password Protected
        </h2>
        <p class="password-description">
            This article is protected. Please enter the password to continue reading.
        </p>
        <form onsubmit={handleDecrypt} class="password-form">
            <div class="input-wrapper">
                <input
                    type="password"
                    placeholder="Enter password"
                    bind:value={password}
                    class="password-input"
                    autocomplete="off"
                />
            </div>
            {#if error}
                <p class="error-message">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
                    </svg>
                    Incorrect password, please try again
                </p>
            {/if}
            <button type="submit" class="unlock-button">
                <span>Unlock Article</span>
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z" clip-rule="evenodd" />
                </svg>
            </button>
        </form>
    </div>
{/if}

<style>
    /* Fix horizontal alignment as marked doesn't generate anchor links */
    .decrypted-content :global(h1),
    .decrypted-content :global(h2),
    .decrypted-content :global(h3),
    .decrypted-content :global(h4),
    .decrypted-content :global(h5),
    .decrypted-content :global(h6) {
        text-indent: 0.75rem;
    }

    /* Password Container */
    .password-container {
        max-width: 480px;
        margin: 3rem auto;
        padding: 2.5rem 2rem;
        border-radius: 16px;
        background: linear-gradient(135deg, 
            oklch(98% 0.01 var(--hue)) 0%, 
            oklch(96% 0.02 var(--hue)) 100%);
        border: 1px solid oklch(92% 0.02 var(--hue));
        box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 
                    0 2px 4px -2px rgb(0 0 0 / 0.05);
        transition: all 0.3s ease;
    }

    :global(.dark) .password-container {
        background: linear-gradient(135deg, 
            oklch(22% 0.02 var(--hue)) 0%, 
            oklch(18% 0.015 var(--hue)) 100%);
        border: 1px solid oklch(28% 0.03 var(--hue));
        box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 
                    0 2px 4px -2px rgb(0 0 0 / 0.2);
    }

    /* Lock Icon */
    .lock-icon {
        width: 56px;
        height: 56px;
        margin: 0 auto 1.5rem;
        padding: 14px;
        border-radius: 12px;
        background: var(--primary-color);
        color: white;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .lock-icon svg {
        width: 100%;
        height: 100%;
    }

    /* Title */
    .password-title {
        font-size: 1.5rem;
        font-weight: 700;
        color: var(--text-color);
        margin: 0 0 0.75rem 0;
        text-align: center;
        letter-spacing: -0.025em;
    }

    /* Description text */
    .password-description {
        font-size: 0.9375rem;
        color: var(--text-color-lighten);
        margin: 0 0 2rem 0;
        text-align: center;
        line-height: 1.5;
    }

    /* Form */
    .password-form {
        display: flex;
        flex-direction: column;
        gap: 1rem;
    }

    /* Input Wrapper */
    .input-wrapper {
        position: relative;
    }

    /* Input */
    .password-input {
        width: 100%;
        padding: 0.875rem 1rem;
        font-size: 1rem;
        border: 2px solid oklch(90% 0.01 var(--hue));
        border-radius: 10px;
        background: oklch(100% 0 0);
        color: var(--text-color);
        transition: all 0.2s ease;
        outline: none;
    }

    :global(.dark) .password-input {
        background: oklch(25% 0.02 var(--hue));
        border-color: oklch(35% 0.03 var(--hue));
        color: var(--text-color);
    }

    .password-input:focus {
        border-color: var(--primary-color);
        box-shadow: 0 0 0 3px oklch(70% 0.15 var(--hue) / 15%);
    }

    .password-input::placeholder {
        color: var(--text-color-lighten);
        opacity: 0.6;
    }

    /* Error Message */
    .error-message {
        display: flex;
        align-items: center;
        gap: 0.5rem;
        padding: 0.75rem 1rem;
        background: oklch(95% 0.05 0 / 50%);
        border: 1px solid oklch(70% 0.15 0);
        border-radius: 8px;
        color: oklch(50% 0.15 0);
        font-size: 0.875rem;
        margin: 0;
    }

    :global(.dark) .error-message {
        background: oklch(30% 0.05 0 / 30%);
        border-color: oklch(50% 0.12 0);
        color: oklch(75% 0.12 0);
    }

    .error-message svg {
        width: 18px;
        height: 18px;
        flex-shrink: 0;
    }

    /* Unlock Button */
    .unlock-button {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 0.5rem;
        width: 100%;
        padding: 0.875rem 1.5rem;
        font-size: 1rem;
        font-weight: 600;
        color: white;
        background: var(--primary-color);
        border: none;
        border-radius: 10px;
        cursor: pointer;
        transition: all 0.2s ease;
        margin-top: 0.5rem;
    }

    .unlock-button:hover {
        background: oklch(65% 0.15 var(--hue));
        transform: translateY(-1px);
        box-shadow: 0 4px 12px oklch(70% 0.15 var(--hue) / 30%);
    }

    .unlock-button:active {
        transform: translateY(0);
    }

    .unlock-button svg {
        width: 18px;
        height: 18px;
        transition: transform 0.2s ease;
    }

    .unlock-button:hover svg {
        transform: translateX(2px);
    }

    /* Responsive Adjustments */
    @media (max-width: 640px) {
        .password-container {
            margin: 2rem 1rem;
            padding: 2rem 1.5rem;
        }

        .password-title {
            font-size: 1.25rem;
        }

        .password-description {
            font-size: 0.875rem;
        }
    }
</style>

Component Highlights:

  • Uses Svelte 5 $props() and $state() syntax.
  • Renders Markdown via marked upon successful decryption.
  • Applies the article class to reuse existing blog post styles.
  • Fixes header alignment issues via text-indent.
  • Modern UI with full dark mode support.

Step 4: Add Logic to the Page Template

Modify src/pages/posts/[...slug].astro to include encryption logic in the frontmatter:

---
import { getCollection, render } from "astro:content";
import { IdToSlug } from "../../utils/hash";
import PostLayout from "../../layouts/PostLayout.astro";
import PostProtector from "../../components/PostProtector.svelte";
import CryptoJS from "crypto-js";

export async function getStaticPaths() {
  const postEntries = await getCollection("posts");
  return postEntries.map((entry) => ({
    params: { slug: IdToSlug(entry.id) },
    props: { entry },
  }));
}

const { entry } = Astro.props;

// Get Content + headings
const { Content, headings, remarkPluginFrontmatter } = await render(entry);

// --- Core Encryption Logic ---
const password = entry.data.password;
let encryptedContent: string | null = null;

if (password && entry.body) {
  // 1. Get raw Markdown content
  const markdownContent = entry.body;
  
  // 2. Add magic prefix for verification
  const contentToEncrypt = `__P_VALID__${markdownContent}`;
  
  // 3. Encrypt using AES-256 with the frontmatter password
  encryptedContent = CryptoJS.AES.encrypt(
    contentToEncrypt,
    String(password),
  ).toString();
}
---

{
  !entry.data.licenseName && !password && (
    <PostLayout
      title={entry.data.title}
      subTitle={entry.data.description}
      bannerImage={entry.data.cover}
      published={entry.data.published}
      lastModified={remarkPluginFrontmatter?.lastModified}
      headings={headings}
    >
      <div
        class="hidden"
        data-pagefind-body
        data-pagefind-weight="10"
        data-pagefind-meta="title"
      >
        {entry.data.title}
      </div>
      <div data-pagefind-body>
        <Content />
      </div>
    </PostLayout>
  )
}
{
  entry.data.licenseName && !password && (
    <PostLayout
      title={entry.data.title}
      subTitle={entry.data.description}
      bannerImage={entry.data.cover}
      published={entry.data.published}
      lastModified={remarkPluginFrontmatter?.lastModified}
      license={{ name: entry.data.licenseName, url: entry.data.licenseUrl }}
      author={entry.data.author}
      sourceLink={entry.data.sourceLink}
      headings={headings}
    >
      <div
        class="hidden"
        data-pagefind-body
        data-pagefind-weight="10"
        data-pagefind-meta="title"
      >
        {entry.data.title}
      </div>
      <div data-pagefind-body>
        <Content />
      </div>
    </PostLayout>
  )
}
{
  password && (
    <PostLayout
      title={entry.data.title}
      subTitle={entry.data.description}
      bannerImage={entry.data.cover}
      published={entry.data.published}
      lastModified={remarkPluginFrontmatter?.lastModified}
      license={entry.data.licenseName ? { name: entry.data.licenseName, url: entry.data.licenseUrl } : undefined}
      author={entry.data.author}
      sourceLink={entry.data.sourceLink}
      headings={headings}
    >
      <PostProtector client:load encryptedContent={encryptedContent!} />
    </PostLayout>
  )
}

Key Execution Details:

  1. Imports PostProtector and CryptoJS.
  2. Checks for entry.data.password.
  3. If present, encrypts entry.body (Raw Markdown).
  4. Passes ciphertext to PostProtector.
  5. Uses client:load to ensure hydration.

Usage Guide

Simply add a password field to your post’s frontmatter:

---
title: "Sample Encrypted Post"
published: 2026-02-10
description: "This is a protected article"
password: "yourPasswordHere123"
---

# Protected Content

This text is only visible after entering the correct password.

## Features

- AES-256 Encryption
- Client-side Decryption
- Validation Check

Even if someone views the source code, they will only see encrypted junk.

Verifying Encryption

Method 1: Check Component Props

  1. Open DevTools (F12).
  2. Search for encryptedContent in the Elements panel.
  3. You should see something like:
    <astro-island ... props="{&quot;encryptedContent&quot;:[&quot;U2FsdGVkX1+vG...&quot;]}">

Method 2: View Page Source

  1. Right-click and select View Page Source.
  2. Search for PostProtector. You’ll find the encrypted string instead of your actual content.

Method 3: JavaScript Console

Input the following in the console:

document.querySelector('astro-island').getAttribute('props')

Technical Details

Why use text-indent instead of padding-left?

The blog’s title style uses a ::before pseudo-element (the purple block) positioned absolutely. padding-left moves the entire header (including the block). text-indent pushes only the text, maintaining the gap between the block and the title.

Encryption of entry.body vs Rendered HTML

Astro’s render() returns a component, not a string. Encrypting raw Markdown (entry.body) and rendering it on the fly with marked is the most reliable way to handle client-side rendering.

Style Adaptation

The decrypted container uses the article and prose classes to ensure full compatibility with your blog’s existing design system and Tailwind Typography.

Security Considerations

Content Safety

  • Standard AES-256 implementation.
  • Content is encrypted before being written to disk during build.

Source Code Safety

Warning: If your source code is in a public Git repository, anyone can see your password in the .md files.

Recommendations:

  1. Add sensitive .md files to .gitignore.
  2. OR keep your repository private.

Once built, the static files are secure regardless of repository visibility.

SEO Impact

Encrypted posts will not be indexed by search engines. This is typically desired for private content.

Conclusion

This system offers:

Real Encryption: Not just “hidden” via CSS.
Zero Backend: Works on any static host.
Privacy: Search engines and scrapers see nothing.
Modern UI: Polished, theme-aware interface.

You can now safely publish private notes or journals on your Astro blog!

Post Encryption System

Author

Shayne Wong

Publish Date

02 - 10 - 2026

Last Modified

02 - 10 - 2026

Avatar
Shayne Wong

All time is no time when it is past.