> ## Documentation Index
> Fetch the complete documentation index at: https://archie.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Reading encrypted data

> Encrypted fields return ciphertext by default. To read the plaintext, a caller signs the request with the project's private key and sends three decrypt headers.

An encrypted field is returned as an **AENC envelope** — a Base64 string that always begins with `QUVO` — on a normal read. To receive the plaintext, a request must prove it is authorized by **signing a canonical string** with the project's [private key](/docs/features/backend/app-services/encryption/overview) and attaching three headers.

Authorization is checked per request. A request with no signature, or an invalid one, gets the ciphertext back — the read **never fails** because decryption was not authorized. This keeps ordinary reads working while gating plaintext behind the signature.

## The decrypt headers

Send all three headers together. They are all-or-nothing — a partial set is ignored.

| Header                | Value                                                                 |
| --------------------- | --------------------------------------------------------------------- |
| `X-Decrypt-Signature` | Base64 of the Ed25519 signature over the canonical string.            |
| `X-Decrypt-Timestamp` | The timestamp used in the canonical string. Unix seconds or RFC 3339. |
| `X-Decrypt-Nonce`     | A unique, single-use random value for this request.                   |

## The canonical string

The signature is computed over a canonical string the server reconstructs from **its own trusted context** — you sign the same shape the server will rebuild. Fields are joined with a newline (`\n`):

```text theme={null}
v1
{projectId}
{environment}
{method}
{timestamp}
{nonce}
```

| Field           | Value                                                   |
| --------------- | ------------------------------------------------------- |
| `v1`            | Literal version prefix.                                 |
| `{projectId}`   | Your project ID.                                        |
| `{environment}` | The environment you are calling (for example `master`). |
| `{method}`      | `query` for reads.                                      |
| `{timestamp}`   | The same value sent in `X-Decrypt-Timestamp`.           |
| `{nonce}`       | The same value sent in `X-Decrypt-Nonce`.               |

<Warning>
  Sign the raw bytes of the exact string above — real newline characters, no trailing newline, no extra whitespace. A single mismatched byte produces a different signature and the value comes back as ciphertext.
</Warning>

## Signing algorithm

The key pair is **Ed25519**. To authorize a read:

<Steps>
  <Step title="Build the canonical string">
    Assemble `v1\n{projectId}\n{environment}\nquery\n{timestamp}\n{nonce}` using a fresh timestamp and a fresh random nonce.
  </Step>

  <Step title="Sign it">
    Sign the UTF-8 bytes of that string with your Ed25519 private key.
  </Step>

  <Step title="Base64-encode the signature">
    Base64-encode the 64-byte signature — that is the `X-Decrypt-Signature` value.
  </Step>

  <Step title="Send the request with the three headers">
    Attach `X-Decrypt-Signature`, `X-Decrypt-Timestamp`, and `X-Decrypt-Nonce`, then run your query. Encrypted fields in the response come back as plaintext.
  </Step>
</Steps>

## How the server verifies

The server checks each step in order and stops at the first failure — returning ciphertext, never an error:

1. **Timestamp skew** — the timestamp must be within the allowed window of the server's clock. A stale or future-dated timestamp is rejected.
2. **Public key** — the server loads the environment's public key. If no key pair has been generated, no request can decrypt.
3. **Signature** — it reconstructs the canonical string from trusted context and verifies the signature against the public key.
4. **Nonce** — only after the signature is valid, the nonce is consumed. Nonces are **single-use**: reusing one (even from a request that failed) is rejected as a replay. Generate a fresh nonce for every request, including retries.

<Note>
  The nonce is consumed only after the signature verifies, so invalid signatures can't exhaust the nonce space. If the replay store is unavailable, verification fails closed — the value stays ciphertext.
</Note>

## Worked example

Signing with `tweetnacl` in JavaScript (for example, in a Postman pre-request script):

```javascript theme={null}
const nacl = require("tweetnacl");
const util = require("tweetnacl-util");

const projectId = "your-project-id";
const environment = "master";
const method = "query";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = util.encodeBase64(nacl.randomBytes(16));

// Build the canonical string — real newlines, no trailing newline.
const canonical = ["v1", projectId, environment, method, timestamp, nonce].join("\n");

// privateKey is the Base64 private key from the Encryption panel (64-byte Ed25519 secret key).
const secretKey = util.decodeBase64(privateKey);
const signature = nacl.sign.detached(util.decodeUTF8(canonical), secretKey);

// Attach these to the request.
const headers = {
  "X-Decrypt-Signature": util.encodeBase64(signature),
  "X-Decrypt-Timestamp": timestamp,
  "X-Decrypt-Nonce": nonce,
};
```

Then run any read that returns the encrypted field. With a valid signature the field is plaintext; without it, the field is the `QUVO…` ciphertext.

## CORS

If you call the API from a browser, the three decrypt headers must be allowed by CORS. The auto-generated GraphQL endpoint already allows them. For a browser app hitting the API directly, configure the allowed request headers under **Backend → Settings → Network**.

## FAQ

<AccordionGroup>
  <Accordion title="Why did my field come back as ciphertext even though I sent a signature?">
    The signature didn't verify against the reconstructed canonical string. Check that the project ID, environment, `method` (`query`), timestamp, and nonce in your signed string exactly match what you sent in the headers — including newline characters and no trailing newline. Also confirm the timestamp is current and the nonce is fresh.
  </Accordion>

  <Accordion title="Can I reuse a nonce or a signature?">
    No. Nonces are single-use and are rejected on reuse as a replay — even if the first request failed. Generate a fresh timestamp and nonce for every request, including retries.
  </Accordion>

  <Accordion title="Does a failed decrypt return an error?">
    No. Authorization is fail-open to ciphertext: a missing or invalid signature returns the encrypted value, not an error. Only the data write path can error on encryption.
  </Accordion>

  <Accordion title="Do related and nested records decrypt too?">
    Yes. Encrypted fields on related records honor the same signed authorization as top-level fields, so a record and its relations decrypt consistently in one request.
  </Accordion>
</AccordionGroup>
