Skip to Content
ConceptsSecurity

Security

What Synq protects, how, and where the responsibility line sits between Synq and your app.

Read this page once before you ship to production. Most of it is the shape of the standards; the few Synq-specific decisions are called out.

Token signing

Access tokens and ID tokens are RS256 JWTs signed with a 2048-bit RSA key. The public half is published at the JWKS endpoint:

GET https://synq.id/.well-known/jwks.json

Your verifier must:

  1. Cache the JWKS by kid.
  2. Refetch on a kid cache miss (not a hard refresh).
  3. Reject tokens whose alg is not RS256.
  4. Validate iss, aud, exp, and iat on every token.

jose’s createRemoteJWKSet does steps 1–4 correctly. Other JWT libraries’ default behaviors vary; check yours.

Key rotation

Synq rotates signing keys periodically. The JWKS document publishes all currently-valid keys; old keys remain published until every token signed with them has expired (1 hour for access tokens, 30 days max for any ID token). Then they’re removed.

Your verifier doesn’t need to do anything special — the next request that hits a token signed with a new key will trigger a JWKS cache refresh, the new key appears, verification succeeds.

Don’t pin a kid. Even if today’s tokens all have kid="abc", tomorrow’s may not. Cache by kid and refetch on misses; never hard-code one.

OAuth2 + PKCE

All public clients (SPAs, native apps, CLIs) must use PKCE with code_challenge_method=S256. Synq rejects authorization-code exchanges from public clients without a code_verifier.

Confidential clients (server-side apps with a client_secret): PKCE is optional but recommended (defense in depth, as RFC 9700 advises). Set it up once in your SDK and you never think about it again.

PKCE protects against the authorization code interception attack — a malicious app on the user’s device steals the code from the redirect. Without PKCE, the attacker can exchange it. With PKCE, the attacker also needs code_verifier, which never leaves your code.

Wallet ownership protocol

A user proves ownership of a wallet by signing a Synq-generated challenge message. The message format:

Sign in to <Brand name> Wallet: <wallet address> Nonce: <random> Issued at: <ISO 8601 timestamp> Expires: <ISO 8601 timestamp>

The signature uses the wallet’s native scheme: Ed25519 for Solana.

Synq verifies:

  1. The signature is mathematically valid for the claimed public key and message.
  2. The message bytes are exactly the ones Synq issued (no replay of a different challenge).
  3. The nonce has not been redeemed before.
  4. The current time is within the issued/expires window (5-minute default).
  5. The brand name in the message matches the brand the user is signing in to.

If any check fails, the signature is rejected with a typed error code (see Errors).

Why a message, not a transaction?

Because signing a transaction implies on-chain authorization. We never want to authorize a transaction during sign-in. The user’s funds are never touched; the signature only proves the user has control over the private key associated with the address.

This is intentional. Any auth flow that asks the user to sign a transaction during sign-in is doing it wrong.

BYO credential storage

Per-brand OAuth credentials (BYO Google, Apple, Microsoft, Discord, etc.) are encrypted at rest with AES-256-GCM.

  • The encryption key lives in Synq’s secret store. It never appears in logs, audit records, or API responses.
  • Decryption happens only at sign-in time, only in memory, only on the request path that needs it.
  • Plaintext config is never returned from any API endpoint after the initial PUT.

When you rotate a provider credential, Synq encrypts the new value and overwrites the previous ciphertext. The old plaintext is unrecoverable from Synq’s side.

API key storage

interface ApiKey { prefix: string // first 8 chars, plaintext, indexed encryptedKey: string // AES-256-GCM ciphertext of the rest user?: string org?: string type: 'standard' | 'ephemeral' scopes: string[] // restricts what the key can do ipAllowlist: string[] // CIDR ranges (IPv4/IPv6) }

The plaintext key is returned only once at creation time. Subsequent GET calls return only prefix and metadata. If a key is lost, create a new one and revoke the old.

Synq logs api-key usage by prefix, not full value. A compromised key can be revoked instantly via the API or the dashboard.

API keys can be IP-restricted via ipAllowlist. If set, requests from sources outside any allowed CIDR are rejected with forbidden.

Webhook signing

Every webhook delivery includes an HMAC-SHA256 signature in the Synq-Signature header.

import { createHmac, timingSafeEqual } from 'node:crypto' function verifyWebhook(rawBody: string | Buffer, header: string, secret: string) { const expected = createHmac('sha256', secret) .update(rawBody) .digest('hex') const a = Buffer.from(expected) const b = Buffer.from(header) return a.length === b.length && timingSafeEqual(a, b) }
  • Verify against the raw body bytes, not parsed JSON.
  • Use a timing-safe comparison. A loose === leaks the secret a byte at a time to a timing-attacker.
  • The secret is returned only once at creation/rotation. Store it immediately.

See Webhooks for the full delivery shape, retry behavior, and auto-disable rules.

Session cookies (Synq’s own UIs)

The Synq sign-in surfaces (login pages, consent pages, the user dashboard) use a parent-domain session cookie scoped to Domain=.synq.id, with HttpOnly, Secure, and SameSite=Lax.

This is what lets the dashboard, the consent screen, and the login flow share auth state across app.synq.id, dashboard.synq.id, admin.synq.id, etc.

Your own apps do not use Synq’s cookie. Your app holds its own session (Iron Session, NextAuth’s cookie store, signed JWE, etc.) and uses Synq’s access_token / refresh_token as the durable identity.

Abuse detection

Synq counts failed sign-in attempts per IP, per user, and per device fingerprint. Repeated failures trigger:

  • Captcha challenges on the login surface (ALTCHA — a privacy-preserving proof-of-work, not Cloudflare or reCAPTCHA).
  • Rate limits on /oauth2/token and /oauth2/authorize.
  • Temporary lockouts on individual provider links.

These thresholds aren’t documented (we’d just hand attackers a runbook). If your legitimate traffic hits a captcha, that’s the system working as intended; the captcha is friendly and fast to solve.

Responsibility line

Synq protects:

  • The OIDC issuer surface (token issuance, refresh, revocation, introspection).
  • The BYO credential vault.
  • The sign-in surface (anti-phishing, captcha, abuse detection).
  • Audit logging of every meaningful action.

Your app protects:

  • Your access tokens and refresh tokens after Synq hands them over. Cookies with proper flags, not localStorage.
  • Your client_secret. Server-side only. Never in client code.
  • Your own session layer.
  • Authorization (what scopes mean, what they let users do in your product). Synq tells you what was granted; you enforce.
  • The webhook secret. Treat it like a password.

If you have a question about which side a given responsibility falls on, reach out at support@synq.id — there are no stupid questions in security.

Mental model summary

  • JWT verification is local. Cache JWKS by kid. Refetch on misses. RS256 + iss + aud + exp.
  • PKCE is mandatory for public clients. Pick a good library and it disappears.
  • Wallets sign messages, not transactions. Ever.
  • BYO credentials are encrypted at rest and decrypted only in-memory at sign-in time.
  • Webhook signatures use timing-safe comparison against the raw body.
  • Synq’s cookie is for Synq’s UIs. Your app brings its own session.
Last updated on