Security & Auth
NeuralRepo is designed with security at every layer. This page covers authentication, authorization, encryption, and data isolation.
OAuth Authentication
Section titled “OAuth Authentication”NeuralRepo supports four sign-in methods. The three federated ones are implemented with the Arctic OAuth library:
| Provider | Protocol | Implementation |
|---|---|---|
| GitHub | OAuth 2.0 | Arctic |
| OpenID Connect | Arctic | |
| Apple | Sign in with Apple | Arctic |
| Magic Link | Email-based | Custom |
The auth_accounts table records which providers are linked to an account, with provider one of
github, google, apple, or email.
OAuth Flow
Section titled “OAuth Flow”- User clicks “Sign in with GitHub” (or Google, or Apple).
- The server generates a state parameter and redirects to the provider.
- The provider authenticates the user and redirects back with an authorization code.
- The server exchanges the code for an access token and retrieves the user profile.
- If the email matches an existing user, the account is linked. Otherwise, a new user is created.
- A session is created and returned as an HttpOnly cookie.
Magic Links
Section titled “Magic Links”For users who prefer email-based auth:
- User enters their email address.
- The server generates a one-time token, stores its SHA-256 hash in the
magic_linkstable, and sends the token via email. - The user clicks the link, which contains the token.
- The server hashes the token, looks up a record that is unexpired (15 minutes) and unused, and creates a session.
- The record is marked used with a
used_attimestamp, not deleted — a replayed link finds the row and is rejected.
Session Management
Section titled “Session Management”Sessions are the primary authentication mechanism for the web app.
| Property | Value |
|---|---|
| Token format | 32 random bytes, hex-encoded (64 characters) |
| Cookie name | nrepo_session |
| Storage | SHA-256 hash stored in D1 sessions table |
| Expiry | 30 days |
| Cookie flags | HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=2592000 |
| Auto-refresh | Rolling — every authenticated request pushes expiry back to 30 days from now |
The session is a rolling 30 days rather than a fixed one: any authenticated request extends it,
so an account in daily use never expires, and one that goes quiet for a month does. Requests
carrying the session as a Bearer token are refreshed in the database; requests carrying the cookie
also get a fresh Set-Cookie.
The raw session token is only ever sent to the client as a cookie. The server stores and compares only the SHA-256 hash. This means a database breach does not expose usable session tokens.
API Keys
Section titled “API Keys”API keys provide programmatic access for the CLI, Siri Shortcuts, CI/CD, and custom integrations.
| Property | Value |
|---|---|
| Format | nrp_ prefix + 32 random bytes hex-encoded — 68 characters in total |
| Storage | SHA-256 hash stored in D1 api_keys table |
| Display | Never shown again; the settings list identifies keys by label and the last 4 characters of the key record’s id |
| Scopes | NULL on keys you create — full access. Scopes only exist on MCP OAuth tokens. |
| Revocation | Immediate — the key record is deleted, and nothing is cached |
When a request includes an X-API-Key header:
- The header is ignored unless the value starts with
nrp_. - The server hashes the provided key with SHA-256.
- It looks up the hash in the
api_keystable. - If found, the request is authenticated as the key’s owner, with the key’s scopes.
- The
last_used_attimestamp is updated.
Credentials are checked in a fixed order: Authorization: Bearer (tried as a session token first,
then as an API key or MCP token), then X-API-Key, then the session cookie. MCP OAuth tokens are
not nrp_-prefixed, so they must be sent as Bearer — X-API-Key will not see them.
The full key is shown only once at creation time. It cannot be retrieved later.
BYOK Encryption
Section titled “BYOK Encryption”User-provided AI keys (Anthropic, OpenAI, OpenRouter) are encrypted before storage:
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM (Web Crypto) |
| Key derivation | HKDF-SHA256 over the ENCRYPTION_KEY Workers secret, with a fixed salt and info string |
| IV | Fresh random 12-byte IV per encryption |
| Storage | A single base64 string of IV ‖ ciphertext ‖ auth tag in D1 |
The secret is never used as an AES key directly — it is stretched through HKDF first, so the stored ciphertext does not depend on the secret’s length or entropy distribution. The encryption key is a Workers secret that never appears in code or logs. Decryption happens in-memory only when making an AI request, and the plaintext key is never written to disk or returned via API.
MCP OAuth with PKCE
Section titled “MCP OAuth with PKCE”The MCP integration uses a full OAuth 2.0 with PKCE flow to authorize Claude’s access:
| Property | Value |
|---|---|
| Grant type | Authorization Code with PKCE, plus refresh_token |
| Challenge method | S256 (SHA-256) or plain |
| Client registration | Dynamic Client Registration (RFC 7591) at /mcp/register, kept 30 days |
| Authorization code expiry | 10 minutes, single use |
| Access token expiry | 30 days |
| Refresh token expiry | 90 days, rotated on every refresh |
| Scopes | ideas:read, ideas:write |
The PKCE flow prevents authorization code interception attacks. The MCP client generates a random code_verifier, hashes it to create a code_challenge, and sends the challenge with the authorization request. When exchanging the code for tokens, the server verifies the original verifier matches the stored challenge.
Authorization codes and refresh tokens are both stored as SHA-256 hashes, in
oauth_authorization_codes and oauth_refresh_tokens. Scopes are enforced at the tool boundary:
every MCP tool that writes requires ideas:write, and a token carrying neither scope is refused.
CORS Middleware
Section titled “CORS Middleware”The API uses strict CORS configuration:
| Header | Value |
|---|---|
Access-Control-Allow-Origin | The request origin if it is https://neuralrepo.com or any *.neuralrepo.com subdomain; otherwise APP_URL |
Access-Control-Allow-Methods | GET, POST, PATCH, DELETE, OPTIONS |
Access-Control-Allow-Headers | Content-Type, Authorization, X-API-Key |
Access-Control-Allow-Credentials | true |
Access-Control-Max-Age | 86400 (24 hours) |
Reflection is limited to NeuralRepo’s own domains — an unrecognized origin is answered with the app
URL rather than itself, so the browser blocks it. Non-production environments additionally allow
*.workers.dev (staging) and http://localhost:*.
Rate Limiting
Section titled “Rate Limiting”Rate limits are enforced using Cloudflare KV as a daily counter store. Limits apply only to requests authenticated via API key (X-API-Key header) or Bearer token. Web UI sessions are exempt.
| Plan | Daily Limit |
|---|---|
| Free | 100 requests/day |
| Pro | 10,000 requests/day |
When a limit is exceeded, the API returns 429 Too Many Requests — with no Retry-After header.
The counter key format is rate:{user_id}:{YYYY-MM-DD} with a 24-hour TTL. The date is UTC, so
counters reset at UTC midnight. The limit is per user, not per key: every API key, MCP client, and
CLI install on one account shares the same budget.
Data Isolation
Section titled “Data Isolation”Every user-facing database query in NeuralRepo is scoped by user_id:
SELECT * FROM ideas WHERE user_id = ? AND is_archived = 0Each user’s data is isolated — you cannot access, search, or modify another user’s ideas regardless of the authentication method. Ownership is checked on the read, not just at the route: fetching an idea by id requires the id and the user id to match, so guessing an id gets you a 404.
Security Headers
Section titled “Security Headers”Every response carries a Content-Security-Policy, set by middleware that rebuilds the response so the header lands even on immutable responses passed through from the static-assets binding. The Worker is deliberately configured to run before asset serving for exactly this reason — otherwise the SPA’s own HTML would be returned without a CSP.
| Directive | Value |
|---|---|
default-src | 'self' |
script-src | 'self' — no inline scripts, no CDNs |
style-src | 'self' 'unsafe-inline' plus Google Fonts |
font-src | 'self' plus Google Fonts |
img-src | 'self', data:, blob:, and the Google/GitHub avatar hosts |
connect-src | 'self' plus Stripe |
frame-src | 'self' plus Stripe (checkout) |
object-src | 'none' |
base-uri | 'self' |