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.
- If the account has not accepted the current Terms of Service and Privacy Policy (a new
account, or one signed in after either was updated), the web app shows an acceptance page
before anything else. Until then, every
/api/*call from that session returns403terms_acceptance_required, except reading your profile and deleting your account.
The acceptance gate applies to web sessions. API keys and MCP tokens are not re-prompted when
the documents change. Instead, the flows that create them are gated when the key is created. The two browser flows,
nrepo login and connecting an MCP client, pass through the same acceptance page first. Native
Sign in with Apple on iOS (POST /auth/apple/native) returns 403 terms_acceptance_required
with the documents and no key, until the app sends the request again with terms_accepted: true.
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.
Sending links is rate-limited per UTC clock hour (the count resets on the hour, not an hour after the first link), so the sign-in form can’t be used to flood an inbox:
| Limit | Per hour | Counted by |
|---|---|---|
| Per inbox | 5 links | The address, lowercased, with any +tag removed. For Gmail addresses, dots are ignored too, so j.doe+x@gmail.com and jdoe@gmail.com share one count. The link still goes to the address exactly as typed. |
| Per client IP | 20 links | The connecting IP address. IPv6 addresses are counted per /64 network |
Over either limit, the request returns 429 with Retry-After: 3600 and
Too many sign-in links requested. Try again in an hour. The answer is the same whether or not
the address has an account. The counters live in KV and are not atomic, so a burst of
simultaneous requests can slip a few past the limit.
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 only, and required. A missing code_challenge, a missing code_challenge_method, plain, or a challenge that isn’t 43 base64url characters (a SHA-256 hash) is refused with invalid_request |
| 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.
Every new authorization must use PKCE with code_challenge_method=S256 stated explicitly.
GET /mcp/authorize answers anything else with 400 invalid_request, and the consent form
redirects back with error=invalid_request and no code. Refresh grants carry no PKCE, so a
client that is already connected keeps refreshing. The server metadata at
/.well-known/oauth-authorization-server advertises code_challenge_methods_supported: ["S256"].
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' |