Secure callbacks, tokens, and scopes
Verify raw requests, prevent replay and duplicate side effects, rotate secrets safely, and preserve workspace consent boundaries.
Verify before parsing
Every Nook callback carries:
X-Nook-Timestamp: <unix-seconds>
X-Nook-Signature: v1=<hex-hmac-sha256>
The signed input is exactly:
v1:<timestamp>:<raw-request-body>
Capture the raw bytes/string before any JSON parser, whitespace normalization, body reconstruction, or schema coercion. Reject a missing header, timestamp more than ±300 seconds from your clock, and every signature mismatch.
import { createHmac, timingSafeEqual } from 'node:crypto';
function expectedSignature(secret: string, timestamp: string, rawBody: string) {
const hex = createHmac('sha256', secret)
.update(`v1:${timestamp}:${rawBody}`, 'utf8')
.digest('hex');
return `v1=${hex}`;
}
function constantTimeEqual(left: string, right: string) {
const a = Buffer.from(left, 'utf8');
const b = Buffer.from(right, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
X-Nook-Signature may contain two comma-separated v1= entries during the 24-hour signing-secret
rotation overlap. Accept only when any supplied v1 signature matches any currently configured
current/previous secret. Never fall back to ordinary string equality.
Enforce body and time boundaries
- Set a bounded request-body limit before buffering (the public starter uses 1 MiB).
- Keep host time synchronized; a broken clock must fail requests rather than widen the replay window.
- Accept only
POSTon the two configured callback paths. - Return
404for unknown paths rather than building a general-purpose public API accidentally. - Validate the parsed body against the documented, versioned callback shape after signature verification; reject unknown or malformed required fields before business logic.
- Log only safe type/identifier/error-code metadata, never raw bodies by default.
Distinguish retries from new work
Nook uses at-least-once background delivery. Duplicates are normal.
For lifecycle/events, persist and deduplicate the signed meta.idempotencyKey. Do not dedupe on
X-Nook-Delivery-Id; that ID changes for every retry attempt.
app.installed is the one special bootstrap path:
- if a usable token already exists for the installation, ignore later install callbacks;
- if the same
tokenExchange.attemptIdwas processed, skip that HTTP retry; - otherwise exchange the newest code;
- do not suppress it merely because
meta.idempotencyKeywas seen—each delivery attempt carries a fresh code and attempt ID.
Persist dedupe state for the maximum retry/side-effect window your business action needs. An in-memory set is suitable only for a local prototype.
Token classes are not interchangeable
| Token | Accepted at | Must not be used for |
|---|---|---|
| Client secret | /api/v1/apps/token-exchange request body | Bot API, callback verification |
| Signing secret | Local HMAC verification only | Any Nook API request |
| Bot token | /api/v1/app/* for one installation | User/admin APIs or another installation |
| Response token | The named interaction’s response/follow-up paths | Bot API or another interaction |
| Incoming webhook URL token | That standalone webhook endpoint | App/bot authentication |
The bot token is shown once and stored by Nook only as a hash. A response token is a 15-minute JWT for one interaction and allows at most five follow-ups. Never persist it as a replacement bot token.
Store secrets professionally
- Inject current and previous signing secrets, client secret, and bot tokens from a secret manager.
- Encrypt durable bot-token storage and key it by installation ID.
- Keep local fallback files mode
0600, outside source control and web roots. - Redact
nookcs_,nooksig_,nookbot_, response JWTs, exchange codes, and webhook URLs from logs, tracing, errors, analytics, crash dumps, and support attachments. - Never put secrets in query strings, command arguments, image layers, frontend bundles, or GitHub Actions output.
- Scope service/runtime access so interaction workers cannot enumerate unrelated installations.
Scope and channel policy
Authorization is the intersection of:
valid bot token
∩ active installation and published app
∩ token scopes
∩ current installation scopes
∩ current channel policy (for channel-bound calls)
Scope removal takes effect immediately. A later scope addition does not widen existing tokens; rotate the token after the administrator consents to an upgrade. Giving the bot user a workspace role does not expand API authority.
Channel-policy denial is deliberately non-enumerating: a hidden/disallowed channel behaves like an
unknown channel. Do not probe identifiers or turn 404 into “request broader access.”
Interaction deadlines and response tokens
Return an immediate response within three seconds:
ack— no immediate content;ephemeral— visible only to the invoking member in a channel;message— a channel or consented DM message under the relevant scopes;modal— immediate modal open whenmodals:openis granted.
For slow work, acknowledge first and run the job after the HTTP response. Use the callback’s response token for a delayed response/follow-up. A delayed modal is rejected. Revalidate current installation, scope, channel/DM, and consent policy when the delayed call executes.
Bot DM consent
dms:write never grants cold outreach. A member must interact with the installed app while:
dms:writeis currently granted;- the member’s global App DMs preference is enabled;
- the installation is active.
Consent is not backfilled from an older interaction. A user revocation remains sticky until the user
explicitly re-enables the app. Every unknown/missing/not-member/no-consent/preference-off target
returns the same 403 APP_DM_NOT_ALLOWED; treat it as “stop until a new permitted interaction,” not
as an enumeration oracle.
Rotate without downtime
Signing secret
- Create the new secret in Developer Portal.
- Keep old secret configured as
NOOK_PREVIOUS_SIGNING_SECRET. - Deploy the new current secret before the 24-hour overlap ends.
- Verify callbacks containing two signatures.
- Remove the old secret after the overlap and a successful callback smoke.
Client secret
The previous client secret remains valid for a 24-hour overlap. Deploy the new value to every token exchange worker, then remove the old value from the secret manager after proof.
Bot token
An installation supports at most two active tokens. Create new → deploy and verify → revoke old. Revoking the installation kills every token immediately.
Required negative tests
- missing signature/timestamp →
401, no body handler side effect; - valid signature over different raw bytes →
401; - timestamp outside ±300 seconds →
401; - duplicate event key → one business side effect;
- same delivery ID with different idempotency key → separate logical event;
- bot token on user/admin/external-messaging endpoints → reject;
- response token on bot API → reject;
- revoked/disabled/suspended installation → current runtime gate rejects;
- missing scope and disallowed/private channel → non-enumerating denial;
- stale token after scope addition → still lacks the new scope;
- DM without current consent/preference → uniform
APP_DM_NOT_ALLOWED; - secret rotation overlap → current or previous secret accepted, unrelated secret rejected.