In this article
November 5, 2026
November 5, 2026

Stream your customers' audit logs into their own Snowflake warehouse

Each audit event lands as a row in your customer's own Snowflake table, with key-pair auth, a fixed schema, grants up front, and a freshness check.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Your enterprise customers' security teams already pull identity provider logs, cloud trails and ticket data into one Snowflake warehouse. Your product's audit events should be in there too, and with WorkOS Log Streams they can be: every Audit Log event for an organization is written as a row in a table inside that customer's own Snowflake account, authenticated with an RSA key pair, so no Snowflake password ever changes hands.

Snowflake became a Log Streams destination in June 2026. Most of the setup happens on the customer's side, and most of what goes wrong later comes from skipping a step there. Here's the whole path, plus how to tell the stream is actually delivering.

The path of one audit event. Your app sends the event to WorkOS Audit Logs. WorkOS authenticates to the customer's Snowflake account with a JWT signed by the customer's RSA private key and inserts the event through the Snowflake SQL API. The row lands in the customer's table, next to their identity provider logs, cloud trail and ticketing data, where their security team queries it.
One audit event, one row: WorkOS inserts it through the Snowflake SQL API with a key-pair JWT, and it lands next to everything else the customer's security team already analyzes.

Why stream to the warehouse instead of exporting?

An export is a snapshot someone has to request. A stream is a copy that keeps arriving, in the customer's account, under their own storage and access rules. Once the rows are in Snowflake, the customer applies their own indexing, monitoring and alerting to your events alongside events from the other services they run.

The second reason is retention. WorkOS Audit Log events are retained for 30 days by default and permanently deleted when the retention period ends; the period is set per organization, from one month up to ten years. Rows that have already landed in the customer's warehouse are theirs, kept for as long as their own data policy says, whatever the retention setting on the WorkOS side.

Snowflake is one of seven Log Streams destinations, alongside Datadog, Splunk, Microsoft Sentinel, AWS S3, Google Cloud Storage and a generic HTTPS endpoint. The rest of this post is specific to Snowflake.

What does the customer set up in Snowflake?

Four things, in this order: a user with a key pair, the table, a role with the right grants, and a network rule if the account has a network policy.

A user and a key pair

Snowflake key-pair authentication needs at least a 2048-bit RSA key pair, which you generate with OpenSSL:

  
openssl genrsa 2048 | openssl pkcs8 -topk8 -v2 aes-256-cbc -inform PEM -out rsa_key.p8
openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub
  

The first command produces an encrypted private key and prompts for a passphrase. The passphrase only protects the key file and is never sent to Snowflake, so store it with the key in the secret manager the team already uses. WorkOS asks for it separately, and only when the private key is encrypted.

Create a dedicated user for the stream and register the public key on it. WorkOS signs its authentication JWTs with the fingerprint of the key in the user's RSA_PUBLIC_KEY property, so register it there:

  
CREATE USER workos_audit_logs
  TYPE = SERVICE
  DEFAULT_ROLE = workos_audit_logs_role
  DEFAULT_WAREHOUSE = audit_wh;

ALTER USER workos_audit_logs SET RSA_PUBLIC_KEY = 'MIIBIjANBgkqh...';
  

Two details trip people up. The role running the ALTER USER needs MODIFY PROGRAMMATIC AUTHENTICATION METHODS or OWNERSHIP on the user, and the public key goes in without its BEGIN PUBLIC KEY and END PUBLIC KEY lines. If the stream later refuses to authenticate, compare the user's RSA_PUBLIC_KEY_FP from DESC USER with the SHA-256 fingerprint of the public key file before changing anything else.

The table, exactly as documented

Each event is written as one row, and the target table has to define these columns:

Column Type Description
event_id VARCHAR Unique identifier for the event
event_type VARCHAR The event action, for example user.signed_in
occurred_at TIMESTAMP_TZ When the event occurred
organization_id VARCHAR The organization the event belongs to
data VARIANT The full event payload as JSON
  
CREATE TABLE security.vendor_logs.workos_audit_events (
  event_id        VARCHAR,
  event_type      VARCHAR,
  occurred_at     TIMESTAMP_TZ,
  organization_id VARCHAR,
  data            VARIANT
);
  

Use those names and types as written. occurred_at keeps its time zone, which keeps the freshness check later honest, and data is VARIANT, so the full payload stays queryable with Snowflake's JSON path syntax and the customer doesn't need a new column every time you add an event type.

Grants, before the first insert

WorkOS doesn't pick a Snowflake role. Its requests name the warehouse, database and schema but no role, so the Snowflake SQL API runs every insert as the user's DEFAULT_ROLE. Grant that role what it needs, and make sure it really is the default:

  
CREATE ROLE workos_audit_logs_role;
GRANT ROLE workos_audit_logs_role TO USER workos_audit_logs;
ALTER USER workos_audit_logs SET DEFAULT_ROLE = workos_audit_logs_role;

GRANT USAGE ON WAREHOUSE audit_wh TO ROLE workos_audit_logs_role;
GRANT USAGE ON DATABASE security TO ROLE workos_audit_logs_role;
GRANT USAGE ON SCHEMA security.vendor_logs TO ROLE workos_audit_logs_role;
GRANT INSERT ON TABLE security.vendor_logs.workos_audit_events TO ROLE workos_audit_logs_role;
  

INSERT on the table isn't enough by itself: a role also needs USAGE on the database and schema that contain it, and on the warehouse that runs the statement. Make all of these grants before you configure the stream in WorkOS, so the connection check that runs when you save has everything it needs. Later, if someone changes the user's default role, the stream loses its grants with it.

A network rule, if the account has a network policy

WorkOS streams audit logs from a fixed set of IP addresses, published in the Log Streams docs for destinations that restrict access by IP. Snowflake network policies control inbound access and only take effect once they're activated for an account, a user or a security integration. They're also easy to get wrong in one specific way: as soon as an allowed list contains one IPv4 rule, every IPv4 address not on it is blocked.

Snowflake recommends network rules over the older ALLOWED_IP_LIST parameter, and scoping a policy to the users that need it rather than the whole account:

  
CREATE NETWORK RULE allow_workos_log_streams
  MODE = INGRESS
  TYPE = IPV4
  VALUE_LIST = ('203.0.113.10', '203.0.113.11');

CREATE NETWORK POLICY workos_log_streams_policy
  ALLOWED_NETWORK_RULE_LIST = ('allow_workos_log_streams');

ALTER USER workos_audit_logs SET NETWORK_POLICY = workos_log_streams_policy;
  

Replace the value list with the current addresses from the Log Streams docs. A policy on a user overrides the account policy, and a policy on a security integration overrides both, so check which one actually governs the stream's user.

How do you configure the stream?

There are two paths, and they produce the same stream.

From the WorkOS dashboard. In the WorkOS dashboard, open the organization, click Configure, pick Snowflake from the dropdown, click Save connection, then fill in the fields below. Use this when you're setting the stream up for the customer with credentials they sent you.

From the Admin Portal. Open the organization in the dashboard, click Invite contact, select Log Streams and click Next, then email the customer's IT contact or copy the setup link. You can also generate the link from your own app with the log_streams portal intent. The contact picks Snowflake and gets step-by-step instructions. This is the better default, because the private key never passes through your support channel.

Either way, the Snowflake fields are the same:

Field What goes in it
Account URL The Snowflake account URL. Use the organization and account name form, https://<orgname>-<account_name>.snowflakecomputing.com
Username The Snowflake user the public key is registered on
Private Key The RSA private key WorkOS uses to sign its authentication JWTs
Private Key Passphrase Optional; only if the private key is encrypted
Warehouse The warehouse that runs the inserts
Database The database that holds the table
Schema The schema that holds the table
Table The table audit log events are written to

How do you know the stream is healthy?

Two checks, and the one worth alerting on lives in Snowflake.

The stream's state. A stream is Active, Inactive, Error or Invalid. Anything other than Active needs a look.

State What it means
Active The stream is working normally and delivering events
Inactive The stream is incomplete, manually disabled or paused
Error The stream hit an error it can't retry, for example after delivery retries were exhausted
Invalid The destination rejected the stream's credentials or configuration

Check it in the dashboard or the Admin Portal. For automated alerting, build on the freshness check below rather than on the state, and don't assume anyone will be notified when a stream stops delivering.

A freshness check in Snowflake. This is the one to alert on, because it measures rows that arrived rather than a connection that was accepted:

  
SELECT
  organization_id,
  MAX(occurred_at) AS latest_event,
  TIMESTAMPDIFF(minute, MAX(occurred_at), CURRENT_TIMESTAMP()) AS minutes_stale
FROM security.vendor_logs.workos_audit_events
GROUP BY organization_id
HAVING minutes_stale > 120;
  

Pick the threshold from the organization's own traffic. A customer whose users sign in all day shouldn't go two hours without an event; one with a handful of admins can go a day, and a two-hour alert there is noise. That's why the threshold belongs to the customer rather than a default.

What happens when a stream breaks?

When delivery retries are exhausted, a stream moves from Active to Error. When the destination rejects the stream's credentials or configuration, it moves to Invalid. Neither state clears by itself: a stream only goes back to Active when someone updates its configuration and validation succeeds.

Log stream states. An Active stream moves to Error when delivery retries are exhausted, and to Invalid when the destination rejects its credentials or configuration. A disabled stream is Inactive. Error and Invalid never return to Active on their own: someone fixes the cause and re-saves the configuration, validation passes, and delivery resumes from the last delivered event, for events still within retention.
Stream states: Error and Invalid only return to Active after someone fixes the cause and re-saves the stream, and the backlog is limited to what's still within retention.
  1. Find the cause. A rotated or mistyped private key, a missing grant, a default role that changed, a network policy without the WorkOS addresses, or a dropped or renamed table.
  2. Fix it in Snowflake, then re-save the stream in the dashboard or through a new Admin Portal link. The stream returns to Active once validation passes.
  3. Check the gap. When a stream that has delivered before becomes Active again, delivery resumes from the last successfully delivered event, and events from the broken window are delivered as long as they're still within the organization's retention period.

That last point sets the repair deadline. With the default 30-day retention, an outage discovered in week six has already cost the customer events that no longer exist to deliver. Key rotation is the predictable version of the same problem. Snowflake lets a user hold two public keys, so rotate without a gap: register the new public key in RSA_PUBLIC_KEY_2, re-save the stream with the new private key, confirm rows are still arriving, and only then remove the old key with ALTER USER workos_audit_logs UNSET RSA_PUBLIC_KEY.

What's on the checklist?

Step Owner Done when
Create the user and a 2048-bit or larger RSA key pair Customer Private key and passphrase are in a secret manager
Register the public key on the user Customer RSA_PUBLIC_KEY_FP matches the key file
Create the table with the documented columns Customer event_id, event_type, occurred_at, organization_id and data all present
Grant the role and set it as the user's default Customer The user can insert a test row as that role
Allow the WorkOS IP addresses, if a network policy is active Customer The rule is on the policy that governs the stream's user
Configure the stream in the dashboard or Admin Portal You or the customer's IT contact The stream's state is Active
Confirm rows are arriving Either The table has rows from the last few minutes of activity
Add a freshness alert on MAX(occurred_at) Customer The alert fires when the stream is paused on purpose
Decide who acts when the stream leaves Active You The contact is in the runbook, not in someone's inbox

The work that pays off later is the boring half: grants and the default role set before the first insert, and a freshness alert that treats silence as a problem. Get those right and the customer's security team gets what they asked for, your events sitting next to everything else in their own warehouse. For more on what's in those events, see WorkOS Audit Logs now capture sessions and membership changes automatically and Audit logs for AI agents: Lessons from Claude's Compliance API.

Further reading