Skip to main content

Overview

rs-tunnel implements a security model designed to protect sensitive credentials and enforce strict access controls. The API holds all provider secrets, while the CLI receives only the minimum credentials needed to run tunnels.

Security Model

From AGENTS.md:49-54:
Security model
  • Provider secrets (CLOUDFLARE_API_TOKEN, Slack secrets, JWT secrets) belong only in API runtime.
  • CLI receives short-lived tunnel run token from API; never provider token.
  • Do not log secrets, JWTs, refresh tokens, or Cloudflare tokens.
  • Keep least-privilege scopes for Cloudflare token (Tunnel + DNS only).
The CLI must never hold Cloudflare API credentials. This is a non-negotiable security constraint.

Token Separation

The system uses two types of tokens with clear separation of concerns:

Cloudflare API Token

Held by: API server onlyUsed for: Creating tunnels, managing DNS recordsScope: Cloudflare Tunnel + DNS edit permissionsNever exposed to: CLI, end users

Cloudflare Tunnel Token

Held by: CLI (short-lived)Used for: Running cloudflared processScope: Single tunnel onlyGenerated by: API server per tunnel

API Token Security

From apps/api/src/config/env.ts:35:
CLOUDFLARE_API_TOKEN: z.string().min(1)
The Cloudflare API token is configured via environment variable and only accessible to the API server.
Use Cloudflare API tokens with minimal scopes: Tunnel edit + DNS edit for the specific zone only.

Tunnel Token Delivery

From apps/api/src/services/tunnel.service.ts:63-92:
const cloudflaredToken = await this.cloudflareService.getTunnelToken(cfTunnel.id);

return {
  tunnelId: dbTunnel.id,
  hostname,
  cloudflaredToken,
  heartbeatIntervalSec: 20,
};
When a tunnel is created, the API:
  1. Creates a Cloudflare tunnel using the API token
  2. Retrieves the tunnel-specific token
  3. Returns the tunnel token to the CLI
The CLI uses this token to run cloudflared but cannot use it to create or delete tunnels.

Authentication Flow

rs-tunnel uses OAuth 2.0 with Slack as the identity provider, plus PKCE for additional security.

PKCE Flow

From apps/api/src/services/auth.service.ts:37-67:
async startSlackAuth(input: {
  email: string;
  codeChallenge: string;
  cliCallbackUrl: string;
}): Promise<{ authorizeUrl: string; state: string }> {
  const email = assertAllowedEmail(input.email, this.env.ALLOWED_EMAIL_DOMAIN);

  const state = randomBytes(24).toString('base64url');
  const expiresAt = new Date(Date.now() + OAUTH_SESSION_TTL_MS);

  await this.repository.createOauthSession({
    email,
    state,
    codeChallenge: input.codeChallenge,
    cliCallbackUrl: input.cliCallbackUrl,
    expiresAt,
  });

  const query = new URLSearchParams({
    response_type: 'code',
    client_id: this.env.SLACK_CLIENT_ID,
    scope: 'openid profile email',
    state,
    redirect_uri: this.env.SLACK_REDIRECT_URI,
  });

  return {
    authorizeUrl: `https://slack.com/openid/connect/authorize?${query.toString()}`,
    state,
  };
}
1

Generate code verifier

CLI generates a random code verifier and computes the SHA-256 code challenge.
2

Start OAuth flow

CLI sends code challenge to API, which creates an OAuth session and returns the Slack authorize URL.
3

User authorizes

User authenticates with Slack in their browser.
4

Exchange code

API exchanges the OAuth code for a Slack access token.
5

Verify code verifier

CLI sends code verifier to API, which validates it matches the challenge.
6

Issue tokens

API issues JWT access token and refresh token.

PKCE Verification

From apps/api/src/services/auth.service.ts:146-149:
const challenge = createCodeChallenge(input.codeVerifier);
if (challenge !== session.codeChallenge) {
  throw new AppError(400, 'INVALID_CODE_VERIFIER', 'PKCE code verifier mismatch.');
}
The PKCE flow protects against authorization code interception attacks.
PKCE (Proof Key for Code Exchange) adds security to OAuth flows for public clients like the CLI.

JWT and Refresh Tokens

Access Token (JWT)

From apps/api/src/services/token.service.ts:12-17:
signAccessToken(payload: AccessTokenPayload): string {
  return jwt.sign(payload, this.env.JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: `${this.env.JWT_ACCESS_TTL_MINUTES}m`,
  });
}
Configuration:
JWT_ACCESS_TTL_MINUTES: z.coerce.number().int().positive().default(15)
  • Default TTL: 15 minutes
  • Algorithm: HS256
  • Signed with: JWT_SECRET environment variable
Generate a strong, random JWT_SECRET of at least 32 characters. Never commit secrets to version control.

Refresh Token

From apps/api/src/services/token.service.ts:39-41:
generateRefreshToken(): string {
  return randomBytes(48).toString('base64url');
}
Configuration:
REFRESH_TTL_DAYS: z.coerce.number().int().positive().default(30)
  • Default TTL: 30 days
  • Format: 48 random bytes, base64url encoded
  • Storage: SHA-256 hash stored in database

Token Hashing

From apps/api/src/services/token.service.ts:43-45:
hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}
Refresh tokens are hashed before storage to prevent token theft from database compromises.
If the database is compromised, attackers cannot use the hashed refresh tokens to authenticate.

Refreshing Access Tokens

From apps/api/src/services/auth.service.ts:170-186:
async refreshTokens(refreshToken: string): Promise<TokenPair> {
  const tokenHash = this.tokenService.hashToken(refreshToken);
  const record = await this.repository.getActiveRefreshTokenWithUser(tokenHash);

  if (!record) {
    throw new AppError(401, 'INVALID_REFRESH_TOKEN', 'Refresh token is invalid or expired.');
  }

  await this.repository.revokeRefreshToken(tokenHash);

  return this.issueTokenPair({
    id: record.user.id,
    email: record.user.email,
    slackUserId: record.user.slackUserId,
    slackTeamId: record.user.slackTeamId,
  });
}
When refreshing tokens:
  1. The old refresh token is revoked (rotation)
  2. A new access token and refresh token are issued
  3. This prevents refresh token reuse attacks

Access Controls

Allowed Email Domain

From apps/api/src/config/env.ts:28-31:
ALLOWED_EMAIL_DOMAIN: z
  .string()
  .min(3)
  .transform((value) => normalizeEmailDomain(value))
From AGENTS.md:41:
Only emails ending in ALLOWED_EMAIL_DOMAIN are allowed.
Example configuration:
ALLOWED_EMAIL_DOMAIN=@example.com
From apps/api/src/services/auth.service.ts:86-88:
const email = assertAllowedEmail(slackProfile.email ?? '', this.env.ALLOWED_EMAIL_DOMAIN);
if (normalizeEmail(session.email) !== email) {
  throw new AppError(403, 'EMAIL_MISMATCH', 'Authenticated Slack user email does not match requested email.');
}
Users with emails outside the allowed domain are rejected during OAuth callback, even if they successfully authenticate with Slack.

Allowed Slack Workspace

From apps/api/src/config/env.ts:32:
ALLOWED_SLACK_TEAM_ID: z.string().min(1)
From AGENTS.md:42:
Slack workspace must match ALLOWED_SLACK_TEAM_ID.
From apps/api/src/services/auth.service.ts:98-100:
if (slackTeamId !== this.env.ALLOWED_SLACK_TEAM_ID) {
  throw new AppError(403, 'WORKSPACE_NOT_ALLOWED', 'Slack workspace is not allowed.');
}
This ensures only users from a specific Slack workspace can authenticate. Example configuration:
ALLOWED_SLACK_TEAM_ID=T01234567
Find your Slack team ID in your workspace settings or from the Slack API.

Security Best Practices

Secret Management

Environment Variables

Store secrets in .env files (never committed) or environment variables.
JWT_SECRET=$(openssl rand -base64 32)
CLOUDFLARE_API_TOKEN=...

Secret Rotation

Rotate secrets periodically:
  • JWT_SECRET: Invalidates all sessions
  • Cloudflare API token: Update in provider dashboard
  • Slack secrets: Regenerate in Slack app settings

Logging Security

From AGENTS.md:53:
Do not log secrets, JWTs, refresh tokens, or Cloudflare tokens.
Never log:
  • JWT_SECRET
  • CLOUDFLARE_API_TOKEN
  • SLACK_CLIENT_SECRET
  • User access tokens or refresh tokens
  • Cloudflare tunnel tokens
Safe to log:
  • User email (for audit purposes)
  • Tunnel IDs
  • Request metadata (paths, status codes)
  • Error messages (without token values)
Review all log statements to ensure no secrets are leaked. Use structured logging with explicit field whitelisting.

Least Privilege

From AGENTS.md:54:
Keep least-privilege scopes for Cloudflare token (Tunnel + DNS only).
Cloudflare API Token Permissions:
  • Account: Cloudflare Tunnel (Edit)
  • Zone: DNS (Edit) - for the specific zone only
Do not grant:
  • Account-level admin permissions
  • Zone-level admin permissions
  • Access to other zones
  • Billing or API token management permissions
Create a scoped API token in the Cloudflare dashboard under “My Profile” → “API Tokens”.

Network Security

HTTPS Only

Always run the API behind HTTPS in production.
API_BASE_URL=https://api.tunnel.example.com

Rate Limiting

API routes have rate limits to prevent abuse:
  • Telemetry: 1200 req/min
  • Auth endpoints: Standard limits
Configure via Fastify rate limit plugin.

Database Security

Connection Encryption

Use SSL/TLS for database connections in production.
DATABASE_URL=postgres://user:pass@host/db?sslmode=require

Credential Storage

Store refresh tokens as SHA-256 hashes, not plaintext.Implemented in apps/api/src/services/token.service.ts:43-45

Audit Logging

The system logs security-relevant events for audit purposes:

Logged Events

From apps/api/src/services/auth.service.ts:116-120:
await this.repository.createAuditLog({
  userId: user.id,
  action: 'auth.oauth.authorized',
  metadata: { email },
});
Audit log events:
  • auth.oauth.authorized: User completed OAuth flow
  • tunnel.created: Tunnel created
  • tunnel.stopped: Tunnel stopped (includes reason)

Audit Log Query

Query audit logs to investigate security incidents:
SELECT created_at, user_id, action, metadata
FROM audit_logs
WHERE action LIKE 'auth.%'
ORDER BY created_at DESC
LIMIT 100;
Retain audit logs for compliance and forensic analysis. Consider exporting to a SIEM system.

Incident Response

If a security incident occurs:
1

Revoke compromised tokens

If refresh tokens are compromised, revoke all tokens for affected users:
await this.repository.revokeAllUserRefreshTokens(userId);
2

Rotate secrets

Rotate JWT_SECRET, Cloudflare API token, and Slack secrets.
3

Review audit logs

Investigate audit logs to determine the scope of the compromise.
4

Notify users

If user data was exposed, notify affected users per your privacy policy.

Security Checklist

Before deploying to production:

Secrets

  • Strong, random JWT_SECRET (32+ chars)
  • Scoped Cloudflare API token
  • Secrets stored in environment, not code
  • No secrets in logs

Access Controls

  • ALLOWED_EMAIL_DOMAIN configured
  • ALLOWED_SLACK_TEAM_ID configured
  • OAuth callback URL is HTTPS
  • Rate limits enabled

Network

  • API served over HTTPS
  • Database connection uses SSL
  • API_BASE_URL uses https://
  • CORS configured appropriately

Monitoring

  • Audit logs enabled
  • Failed auth attempts monitored
  • Alerts for quota exceeded spikes
  • Regular secret rotation schedule

Build docs developers (and LLMs) love