Skip to Content

Permission to act as you, and a value to act on

keys and values
n8n Guide · Part 10

Permission to act as you, and a value to act on

You have wired up Slack, Gmail and Google Sheets. Two questions were never answered properly. What gives a workflow the right to send messages and write spreadsheets as you — that is the credential, one set of account authorization details n8n keeps encrypted. And how does a node put yesterday's order number into today's message — that is the expression, a short piece of code inside {{ }} that n8n evaluates before the node runs. This part covers both, because a node needs both before it does anything useful.

2 resources
A credential and a workflow are stored apart, on purpose
1 key
Lose the encryption key and a restored backup opens nothing
5 forms
The expressions that cover eight cases out of ten
Two resources, not one

The credential and the workflow are separate things

When you dropped a Slack node onto the canvas in Part 8, n8n made you pick a Credential to connect with. What sits in that dropdown is a credential: one SaaS account authorization that n8n holds for you. Without it the Slack node still throws Authentication failed at run time, even when every other parameter is filled in correctly.

In one sentence, a credential is the set of account authorization details n8n keeps for you, so a node can pull them out automatically when it calls an outside service. Think of your browser's saved passwords, but for workflows. Four properties matter more than anything else you will read about them.

It lives in the n8n database

Not on your computer, and not inside the workflow JSON file. Move a workflow to another n8n and you have to bind the credential again on the other side.

It is stored encrypted

Encrypted with the instance's N8N_ENCRYPTION_KEY environment variable. Not even you can read the plaintext back — only the credential's name and its type.

It is user-level, not workflow-level

A credential you create goes into your personal space by default, and other signed-in users cannot see it. The same account can reuse one credential across many workflows and many nodes.

Deleting one has consequences

Delete a credential and every node that references it turns red and stops working. To share it with other people you go through the sharing mechanism, which has real limits — see section 03.

A node inside a workflow stores only a reference: which credential id I use. The token value itself is not in the workflow file. That is the whole design, and it is why you can hand a workflow JSON to a colleague without handing over anything sensitive.

In plain terms

The workflow is a recipe that says “use the good olive oil from the locked cupboard”. You can email that recipe to anybody. It tells them what to do, it names the cupboard, and it contains no key. They still have to have their own key, or be given one deliberately — and that is exactly the point.

flowchart TD
  subgraph W["Safe to hand around"]
    direction LR
    A["workflow_entity table:
the workflow JSON"] --> B["A node holding only
a credential id"] end subgraph C["Never leaves the instance"] direction LR D["credentials table"] --> E["An encrypted blob:
token, API key, refresh token"] F["N8N_ENCRYPTION_KEY
from the environment"] --> E end B -.->|"looked up by id at run time"| D
Where the token actually isTwo tables in one database, drawn as two lanes. The upper lane is what you hand to a colleague: the workflow JSON, and a node carrying nothing but a credential id. The lower lane never leaves the instance, and it is ciphertext that only the encryption key opens. One dotted edge runs down from the node to the credentials table, and it carries an id, not a secret.

This split buys you two concrete things. Sharing a workflow with a colleague leaks no token. And changing a token — a refresh, a different account — means editing one credential, after which every workflow that references it picks up the new value automatically. You do not go around editing nodes one at a time.

Put the two side by side and the design stops being abstract. They live in the same database, in different tables, and everything else follows from that.

QuestionThe credentialThe workflow
Where does it live? The n8n database, the credentials table, encrypted. The n8n database, the workflow_entity table
Can it be exported as JSON? Not recommended on Community — there is a real risk of leaking the plaintext. Yes. Ctrl+S to save, or Download from the menu
How many things can use one copy? One credential can be reused across many workflows and many nodes by the same user. Sharing it across users depends on Sharing / Projects, which is Enterprise only. One workflow can reference several different credentials — read the Sheet with A, post to Slack with B
What happens if you delete it? Every node that references it turns red with Credential ID not found. Deleting the workflow on its own does not affect the credential. The credential stays

The export row is the one that catches people out. A workflow comes out of n8n as a file you can put in git, and that is exactly why the token must not be in it.

The Credentials page is at the bottom of the sidebar. In some versions the icon is a little key. Click in and you get a table listing every credential in your workspace with its name, its type and its last-modified time.

Ask before you create. WoowTech already runs a shared Google OAuth Client and a shared Slack Bot. Before you make a credential, ask IT whether there is a shared resource for that service. Letting everyone open their own OAuth app gets very hard to manage — quota and audit logs end up scattered across a dozen projects nobody owns.
OAuth2 or an API key

Two types cover almost everything

There are hundreds of credential types in n8n. They fall into two groups, and once you recognize those two you can handle almost anything you meet.

TypeHow you authorizeSetup effort
OAuth2 Press Connect, you are sent to the provider's site to sign in, click Allow, come back, and the token arrives automatically. Typical services: Google (Gmail, Sheets, Drive, Calendar), Slack, GitHub, Notion, the Microsoft family, HubSpot, Salesforce. Medium. You need a Client ID and Client Secret, though many built-in credentials come pre-filled
API Key / Bearer Token Create a key in the provider's console, copy it, paste it straight into n8n. Typical services: OpenAI, Anthropic, Airtable, SendGrid, Stripe, Twilio, most developer APIs. Easy. One field, paste the key

Two rarer variants exist as well. Do not be alarmed when you meet them. Basic Auth takes a username and password directly, and is common on older internal APIs and some corporate LDAP setups. Header Auth or Custom Auth covers non-standard APIs that want a special field in the header — it is the usual partner for the HTTP Request node from Part 9 when you call an API in the wild.

These two are not at the same security level. An OAuth token usually expires, can be revoked by the provider, and is limited by scope. An API key usually lasts indefinitely and grants coarser permissions. Prefer OAuth wherever it is offered. Services like OpenAI that only issue API keys are the exception, not the pattern to copy.

Hands-on: a Google OAuth2 credential

The most common situation at work: one workflow has to read and write Google Sheets, send Gmail and store files on Drive, all authorized by the same Google account. Walk through it once and you have it for good.

sequenceDiagram
  participant U as You
  participant N as n8n credential page
  participant G as Google consent screen
  U->>N: Paste the Client ID and Client Secret
  U->>N: Press Sign in with Google
  N->>G: Open a popup at the provider
  U->>G: Pick an account, read the access list, press Allow
  G-->>N: Redirect back to the OAuth Redirect URL
  N-->>U: Green check, and Account connected
  Note over N: The token is written into the credentials table, encrypted
What the Connect button actually doesYou never see the token. It is issued by the provider, handed back through the redirect, and written straight into storage. The one step that goes wrong is the redirect — if the URL does not match on both sides, the popup comes back with nothing.
  1. Step 1

    Credentials, then a new credential

    Click Credentials in the sidebar, then Add credential at the top right. On an empty workspace the button in the middle says Create new credential instead. Either way a “Select credential type” search box opens.

  2. Step 2

    Search for Google, then pick the matching type

    Type google and a pile of options appears: Google OAuth2 API (the generic one), Google Sheets OAuth2 API, Gmail OAuth2 API, Google Drive OAuth2 API and more.

    The rule is simple: pick the OAuth2 credential that matches the node you are going to use. It is all Google OAuth underneath, but the scopes differ. For the Gmail node, pick Gmail OAuth2 API.

  3. Step 3

    Get a Client ID and Client Secret

    If this is your first one, n8n asks for a Client ID and a Client Secret. You get both by creating an OAuth 2.0 Client ID at console.cloud.google.com. The path is Create a Project → APIs & Services → Credentials → Create Credentials → OAuth client ID → Web application.

    On that Google form there is an Authorized redirect URIs box. Into it goes the OAuth Redirect URL that the n8n credential page is showing you — something with this shape:

    https://your-n8n.example.com/rest/oauth2-credential/callback

    Read your own off your own credential page. Do not type the one printed here; the host part is different for every installation. Save the Google form and you have your Client ID and Client Secret.

    Or skip this step entirely. WoowTech IT has already built a shared OAuth Client. Ask them for the Client ID and Client Secret rather than opening a new Project of your own. Sharing one keeps quota management and audit logs in a single place.
  4. Step 4

    Paste them into n8n

    Back in the credential form, put the Client ID in the Client ID field and the Client Secret in the Client Secret field. Leave Scope at its default in most cases — each credential type ships with the scopes people usually need. Gmail OAuth includes send, read and labels; Sheets OAuth includes read and write.

  5. Step 5

    Sign in with Google, then Allow

    Press Sign in with Google at the top right. Other versions label the same button Connect my account or just OAuth2, so recognize the position rather than the word.

    A new window opens on the Google consent screen. Pick your Google account, read the “n8n wants to access your Gmail…” list rather than clicking past it, then press Allow. When the popup closes, the n8n page shows a green check and Account connected.

  6. Step 6

    Name it so a stranger could identify it

    Change the credential name at the top to something recognizable — Gmail ([email protected]) or Google Sheets - CS team. Press Save. From now on that name is what appears in the Credential dropdown of your Gmail, Sheets and Drive nodes.

If the popup stops on redirect_uri_mismatch, the Authorized redirect URIs on the Google Console side are wrong. They have to match the OAuth Redirect URL shown on the n8n credential page character for character — the https, the domain and the trailing slash included. Compare the two sides and copy it across again rather than guessing at it.

Hands-on: an API key credential

The API Key type is far simpler. No bouncing between sites, just one key pasted in. OpenAI as the example:

  1. Step 1

    Create the key in the provider's console

    Sign in at platform.openai.com/api-keys, press Create new secret key, give it a name such as n8n-woow-prod, and choose All permissions — or read-only, if that is genuinely all you need.

    The key is shown exactly once. If you do not copy it now you can never get it back, so paste it into n8n immediately rather than into a scratch file you mean to delete later.

  2. Step 2

    New credential, search for OpenAI

    Back in n8n: sidebar Credentials → Add credential, type openai in the search box, pick OpenAI.

  3. Step 3

    Paste the key and name it

    There is a single API Key field. Paste the key. Organization ID can usually stay empty, unless you belong to several OpenAI organizations and have to name one.

    Rename the credential to something like OpenAI (marketing team) and press Save. Done, and much faster than OAuth.

You do not have to hunt for a Test button. n8n runs a connection test automatically when it saves a credential — a green check or a red cross appears next to the title. Some integrations also offer a separate Test button, though not every type has one, and OAuth replaces Test with Reconnect to re-verify. A green Connection tested successfully means the auth works. It does not mean the scope is enough.
In plain terms

A passing test is the doorman recognizing your face. It is not your pass opening the third floor. The building has let you in; whether you can get into the room you actually came for is a different question, and the only way to answer it is to walk up there and try the door.

Sharing, and where it stops

Three ways to share, and the one most teams cannot use

Not everyone at a company wires up workflows with their own Google account. Usually a team shares one address like [email protected], or uses a role account such as a Slack Bot. n8n's sharing model is the opposite of what most people expect: the default is whoever created it owns it, not shared across the company.

ModeHow to set it, and who can use itBest for
Personal space
(the default)
Change nothing when you create it; the credential lands in the creator's personal space automatically. Only the creator can use it — other signed-in users do not even see it in the dropdown. Every credential on the Community edition goes here. A personal Gmail, a private GitHub token
Sharing
(named people)
Credential detail page, the Sharing tab, add a user email and a role. Whoever is on the list can use it, as viewer or editor. Sharing one account with colleagues on your team. Enterprise and Cloud Pro and above only — Community has no such tab at all
Project sharing Create the credential under a Project, or move it there, and every project member can use it. Their role decides viewer or editor. The company Slack Bot, a shared team mailbox, a shared API key. Enterprise only — Community has no concept of a Project
An important correction, because this catches teams out. The Community edition works the other way round from what people assume. Every credential is locked inside its creator's personal space, and other signed-in users cannot see it or select it at all. Which means: sharing one company Gmail with a colleague is not possible on Community — that colleague has to build their own copy, or everyone signs in as the same owner account. Per-user isolation plus selective sharing, private but with an allowlist, requires Enterprise for the Sharing tab and Projects. Cloud Pro and above has Sharing too.

So what do you actually do? It depends on which edition you are on, and there is no clever workaround for the first case.

CommunityIf you genuinely must share the marketing account's credential, the usual answers are that everyone signs in as the same n8n user — not ideal, but common — or each person rebuilds their own copy. If you need sharing you can rely on, that is an upgrade to Enterprise, not a setting.
Enterprise / Cloud ProUse the Sharing tab to allowlist specific colleagues, or create the company-account credential under a Project so its members share it.
Either wayPut who owns it, which team and what it is for into the name. Slack Bot - #ops-alerts and OpenAI (billing: CS team) are readable a year later. gmail1 and gmail2 are not.

Two workflows that need different Slack accounts are not a problem, incidentally. Create two credentials — Slack Bot - #ops-alerts for one account and Slack Bot - #marketing for the other — and the Slack node in each workflow picks the matching one from its Credential dropdown. A hundred Slack credentials under one workspace is fine.

Storage, encryption, backup

The key you must not lose

Three questions IT always asks: where does it live, how does the encryption work, and what does a backup have to include. All three in one pass, because the third one is the one that ruins weekends.

QuestionAnswerThe part people miss
Where does it live? In the n8n database, in a table called credentials (or credentials_entity, depending on version). One row per credential, with columns including id, name, type and data. Whether you self-host on SQLite or on Postgres does not change this. The data column is the encrypted JSON blob — token, API key, OAuth refresh token and the rest
How is it encrypted? On startup n8n reads the N8N_ENCRYPTION_KEY environment variable and uses it as the symmetric encryption key, AES from crypto-js underneath. Credentials are encrypted with it on the way into the database and decrypted on the way out. If the variable is not set the first time n8n starts, n8n generates one and writes it into the settings file under ~/.n8n — in practice ~/.n8n/config
Can the Owner read a token? No. Even as workspace Owner, the credential detail page shows the token field as blank, meaning saved, or as a row of dots. You never see the real value. One route to the plaintext does exist, and it is worth knowing exactly what it is: reading the database directly with SQL and decrypting the data column with the encryption key. That takes root on the server. So the control stops a curious admin clicking through the UI; it does not stop somebody who already owns the box. That is deliberate. It stops insiders, admins included, copying tokens out by accident or on purpose

In a queue mode deployment, set N8N_ENCRYPTION_KEY by hand and give every worker the same value. Workers that do not share the key cannot decrypt the credentials, and the symptom is a fleet of workers that all fail authentication for no visible reason.

In plain terms

Dumping the database without the encryption key is photographing a safe full of documents and leaving the combination behind. You have a perfect copy of the safe. Every page inside it is still unreadable, and no amount of restoring it again will change that.

Always carry the encryption key along with the backup. Backing up n8n by dumping SQL alone is useless — every credential in the database is ciphertext, and if the new server has a different encryption key on restore, all of them become garbage nobody can open and every workflow dies. Do it properly: keep the DB dump, ~/.n8n/config and the value of the N8N_ENCRYPTION_KEY environment variable together, all three.

WoowTech's standard backup routine goes one item further than that minimum, and it is worth copying:

1The Postgres or SQLite database dump.
2The ~/.n8n/config file — it holds the encryption key.
3The list of environment variables, which may also hold the encryption key.
4The ~/.n8n/binaryData/ attachment directory.
Treat the key like a database password. Do not commit N8N_ENCRYPTION_KEY to git. Do not paste it into Slack. Do not leave it in Notion for anyone to read. Once that key leaks, anyone holding a copy of your database dump can decrypt every credential in it. Store it internally at the same level as your database password and your SSH private key.

Newer n8n versions offer encryption key rotation, which is worth knowing about before you need it. Set N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION=true — it works on every self-hosted version. Once it is on, N8N_ENCRYPTION_KEY becomes a master key that protects an inner data key, and Settings → Data Encryption Keys in the UI lets you rotate that inner key. So you can change the credential encryption on a schedule without touching the master key.

Key rotation is a one-way switch. Once it is on it cannot be turned off. Back up the database before you enable it.

The day-to-day life of a credential

Every credential goes through the same stages from creation to deletion. Learn them once and you can run dozens without breaking a sweat.

stateDiagram-v2
  [*] --> Created
  Created --> Working: saved and tested
  Working --> Expired: refresh fails
  Expired --> Working: Reconnect
  Working --> Deleted: Delete
  Deleted --> [*]
  note right of Deleted
    Every node that referenced it
    turns red: Credential ID not found
  end note
The loop a credential lives inFour named states, read top to bottom, and the transition labels are kept to a few words so they stay readable at this size. Saved and tested is the connection test n8n runs by itself the moment you press Save. Refresh fails is the only way into Expired, and it is rarer than people fear, because most OAuth tokens refresh on their own. Reconnect is the one edge back out of it. The note under Deleted is the consequence people forget. Editing a credential is not drawn, because it does not move you anywhere: you stay in Working, and every node that uses it picks up the new value.
StageHow to do itWatch out
Create Credentials page, Add credential, pick the type, fill the fields, Save Give it a recognizable name; note who owns it, which team, which environment
Edit Click the credential name on the Credentials page, change the fields, Save. Or open it from a node that has gone red Every node that references it picks up the new value automatically — you do not edit them one by one
Test The Test button at the top right of the credential detail page. Most types have one; OAuth uses Reconnect instead A passing test only proves the auth is right, not that the scope matches the node you want to run
Reconnect When an OAuth token refresh fails the credential turns red. Press Reconnect to run the authorization flow again Most OAuth tokens refresh automatically. You do it by hand after a password change, a revoked scope, or a long gap with no runs
Delete Select the credential on the Credentials page, then Delete It affects every workflow that references it. Check the Usage tab first to see which workflows depend on it
Look at Usage before you delete. Most credential detail pages have a Usage or Used by tab listing which workflows and which nodes reference the credential. Thirty seconds there is how you avoid taking down a colleague's workflow by accident.

When credentials go wrong

The OAuth popup comes back and I am still not signed in
You press Sign in with Google, the popup opens the consent screen, you click Allow, and back in n8n it is still gray with no green check. Nine times out of ten the OAuth Redirect URI is wrong. The Authorized redirect URIs in the Google Console must match the URL shown on the n8n credential page exactly — https included, trailing slash included, domain included. Ask IT to compare the two sides and copy it across again.
A credential suddenly expires and stops working
For OAuth types n8n refreshes the token automatically most of the time and you never notice. Four situations make the refresh fail and need a manual Reconnect: the account's password was changed; the app authorization was revoked by hand on the provider's side; the refresh token expired (Google expires one after 6 months of disuse); or the OAuth Client Secret was revoked in the console.
A node turns red with Credential ID not found
Two possibilities. Somebody deleted that credential, in which case create a new one and re-pick it in the node's credential dropdown. Or you exported the workflow JSON from one n8n into another and the credential ids do not line up — again, rebuild the credential in the second instance and bind it again. That is the unavoidable price of keeping credentials and workflows apart.
The Test button returns 401 or 403
Two possibilities. On an API Key type, the key is mistyped, you dropped a character while copying, or the key has already been revoked in the console. On an OAuth type, the scope is not enough — you picked Google Sheets OAuth2, say, but that same OAuth authorization never granted Drive access. Reconnect once more and tick every permission you need on the provider's page.
The Test passes but the node still will not run
Test usually only makes the simplest possible API call to prove the account is alive — a GET /me, for example. Your node may need a more advanced scope or permission than that, and Test never exercises it. Posting to a private Slack channel needs the chat:write.private scope. Writing to a Google Sheet needs the spreadsheets scope, not just spreadsheets.readonly. So Test passes, and the real run throws insufficient_permissions. Reconnect and grant the scope the operation actually needs. The reverse happens too — Test is inaccurate against some APIs, and a rate limit can make the two results disagree. Trust the real run; Test is only a quick smoke test.
After a backup restore, no credential opens
The classic. You restore the database dump on a new server and every credential shows an error, or simply cannot be decrypted. The cause is that N8N_ENCRYPTION_KEY did not come along. The fix: find the old server's encryption key, in ~/.n8n/config or in its environment variables, set the same-named environment variable on the new server, and restart n8n. This is the most common restore disaster there is. Put the key into your backup plan on day one, not after the first incident.
The credential I just created is missing from the dropdown
Two reasons. The credential type does not match the node — you will never find a Gmail credential inside a Slack node, because the Slack node only accepts Slack API or Slack OAuth2. Or the workspace you created it in is not the workspace you are editing the workflow in. Only Enterprise has the project concept; on Community there is usually one workspace with one copy.
A colleague leaves — what happens to their credentials?
Both cases have traps. On Community, every credential is locked inside its creator's personal space, so once the departing employee's account is disabled those credentials are locked in with it. Every workflow that references them turns red, and the owner has to transfer credential ownership out through the database or the CLI, or simply rebuild them. On Enterprise, Sharing and Projects mean an admin can transfer ownership to whoever takes over, from the Users settings. Either way, on the day they leave, re-authorize or revoke in the SaaS console every Gmail, Slack and API key they bound — rotate the token — so an old token cannot reach company data after they are gone.
Can a credential be exported as a JSON file?
On the Community edition, not recommended. In theory the n8n CLI (n8n export:credentials) can export them, but the default is plaintext and the token sits exposed in the file. Put that in git or share it and you have a security incident. If you must export, add --decrypted=false to keep it encrypted, and use it for backup only — do not send it to anyone. If you genuinely need to move credentials between instances, the safest route is rebuilding them by hand in the new environment.
Can I put an API key inside a Code node?
Never. Code node contents are stored as plaintext in the workflow JSON, so anyone with read access to the workflow can see them, and the moment somebody exports the workflow to share it the key goes out with it. Do it properly: create a Header Auth or HTTP Custom Auth credential, put the key in there, and have the Code node or the HTTP Request node reference that credential. The key stays encrypted, and you can share the Code node contents without worrying.
Which is safer, OAuth or an API key?
Use OAuth wherever you can. An OAuth token has a lifetime — it refreshes automatically or it expires — the provider can revoke it unilaterally from their console, and it is limited by scope, read without write for example. An API key usually lasts indefinitely, grants everything the moment you hand it over, and has to be revoked by hand in the provider's console. A few services (OpenAI, Anthropic) only offer API keys, so there you have no choice. Where that is the case: give the key a recognizable name, rotate it on a schedule — every 3 months, for example — and restrict it by IP wherever that is possible.

The official documentation lives in three places worth bookmarking: the overview at docs.n8n.io/credentials/, the OAuth configuration detail at docs.n8n.io/hosting/configuration/oauth/, and sharing and permission management at docs.n8n.io/user-management/rbac/. Each credential type also has its own page describing which fields to fill in.

What an expression is

A dynamic fill-in-the-blank, in five forms

Credentials get a node connected. Expressions get data into it. Part 7 told you that what travels between n8n nodes is items, one row at a time — but knowing what an item looks like is not enough. When a downstream node actually needs the data, you have to put an upstream field into a field on that downstream node, and the bridge for that is the expression.

Here are everyday jobs you cannot build without one:

  • Someone submits an order through a Google Form and Slack posts “New order #1042, customer Chen Xiaoming, amount $3,500”. Those three values come from upstream. You cannot hard-code them.
  • Every day at 08:00 Gmail sends the “2026-08-16 daily sales report”, and the date has to be today, not the day you built it.
  • An IF node condition: notify the manager only when the amount is over 1000. The value being compared has to be pulled from the current item's amount field.
  • Adding a row to Google Sheets, where the name column takes the previous node's name and the Email column takes email.

Without expressions you could only build one workflow per customer, which reduces n8n to a very expensive cron job. Expressions are what make one workflow serve an unlimited number of records, and no other n8n feature saves you more time.

The idea itself is small. Every field inside a node — the Text field on the Slack node, say — can be filled in two ways.

ModeWhat you write, and what happens at run timeExample
Fixed
(static)
You type directly and n8n sends it through untouched. The same text on every run. Type hello world in the field and Slack always sends hello world
Expression
(dynamic)
Wrap it in {{ }} and write a short piece of JavaScript inside. When n8n reaches the node it evaluates what is inside the braces first, then writes the value back. Type {{ $json.name }}; at run time, if the current item's name is Elmo, Slack sends Elmo
In plain terms

It is the difference between a printed leaflet and a form letter. The leaflet says the same thing to everyone. The form letter has “Dear ______” printed once and a different name written into the gap on every envelope — one template, a thousand letters, and nobody retypes anything.

flowchart TD
  A["A field on a node"] --> B{"Fixed or
Expression mode?"} B -->|"Fixed"| C["The text is sent
exactly as typed"] B -->|"Expression"| D["n8n evaluates everything
inside the braces first"] D --> E["The result is written
back into the field"] E --> F["The node runs
with a real value"] C --> F
What the fx toggle changesThe left-hand branch is the one that evaluates. Take Expression down the left and n8n works out whatever is inside the braces, then writes the result back into the field. Take Fixed down the right and the text is sent exactly as typed, braces and all — which is why a Slack message sometimes arrives reading literally as the expression you wrote. Both branches land on the same last box, so the node itself never knows which one you chose.

How to switch: at the field label above the field, or on the field itself, there is an Expression / Fixed toggle. Newer builds show a small fx icon plus an Expression tab, so recognize the shape rather than the exact word. Left is Fixed, right is Expression. Once you switch, the field background turns into a gridded code style and a preview block appears underneath. That gridded background is the visual marker that you are in Expression mode.

A field can also mix static text with an expression. Say the field holds New order {{ $json.orderId }} — the static prefix is sent as it is, and only the braces section gets replaced. That is the most intuitive way to build a template string, and you will use it constantly.

Expressions are a subset of JavaScript plus n8n's own variables. Any JS you already know works — +, .toUpperCase(), the ternary a ? b : c. What you should not do is run heavy logic or call an API in there. That is the job of the Code node, which Part 15 covers.

The five you will use most

Eight cases out of ten come down to these five. Learn them well and everything else is a combination of them.

How you write itWhat it meansWhen to use it
{{ $json.field }} A field on the current item. 90% of cases. The order number into a Slack message, the customer name into a Gmail subject, the email into a new Sheets row
{{ $('Google Sheets').item.json.field }} Data from a specific upstream node, however many stops sit in between. You have been through IF, Set and Merge and still want the original form data
{{ $now }} The current time, as a Luxon DateTime object in the workflow timezone. Equivalent to DateTime.now(). A date in a Gmail subject, a timestamp in a new Sheets row, arithmetic against another date
{{ $json.items.length }} The length of an array. A Slack notification saying how many orders there are, or an IF node checking whether there are more than 10
{{ $json.name.toUpperCase() }} Calling a method on a string. The whole JavaScript String set is available. Normalizing case, cutting a string short, trimming whitespace, replacing text

$json, $now and $() are built-in variables that n8n injects for you. They are not native JavaScript; n8n prepares them and you simply use them. Part 15 spells out the full list — for now, get comfortable with these few.

Two forms of the same thing. {{ $('Google Sheets').item.json.field }} is the newer form, promoted since 0.190 and later. The older form for the same job is {{ $node['Google Sheets'].json.field }}. Both still run, because n8n keeps the old one for backward compatibility. On alignment: $('Node').item explicitly goes through pairedItem to align with the current iteration, and $node['Node'].json also takes the matching item from the current run — the form is missing the .item layer, but the behavior is equivalent. Seeing both mixed in somebody else's workflow is normal.

Build them by dragging instead of typing

Honestly, writing an expression almost never means typing code yourself. The expression editor has a good drag mechanism, so you do not have to remember any field name — one pull with the mouse and it is written correctly. This is the one trick every beginner should learn first, because dragging never misspells.

  1. Step 1

    Run the upstream node once

    Click Execute step on the upstream node — the small play button underneath it — so it produces output. Without output, the expression editor does not know which fields exist and shows an empty schema.

  2. Step 2

    Open the downstream node

    Double-click the node to open its settings panel and find the field you want to change. The Text field on a Slack node is the usual first one.

  3. Step 3

    Switch the field into Expression mode

    Above the field there is a Fixed / Expression tab, or a small fx icon. Click the Expression side. The field background turns into a gridded code style, which is how you know the switch worked.

  4. Step 4

    Drag a field in from the INPUT panel

    Once the expression editor opens, an INPUT panel slides out on the left listing every field from the upstream node. You can switch it between Table, JSON and Schema views. Drag a field straight into the expression box and n8n writes the correct form for you — drag name and you get {{ $json.name }}.

  5. Step 5

    Check the Result preview

    Below the expression box is a Result: line showing, live, the value this expression evaluates to. The value you expected means you are done. Red text or an empty result means the expression has a problem, and section 07 is about reading those.

  6. Step 6

    Type static text around it

    You can add static text before or after the expression you dragged in. If the box holds {{ $json.name }} and you type Hi in front, it becomes Hi {{ $json.name }} and the preview updates immediately to Hi Elmo.

Not sure what a field is called? Switch the INPUT panel to the Schema view. It lists field names and types only, with no values, so the whole structure is clear at a glance. That is exactly why Part 7 makes a point of the Schema view.
Dates, strings, real cases

The two things you will format most

Almost every expression you write beyond {{ $json.field }} is either a date or a string. Both have a small set of moves worth memorizing.

Dates and times: enter Luxon

n8n expressions handle time with Luxon, a JavaScript date library. Not the native Date, and not Moment. Luxon's advantages are timezone support and calendar arithmetic, with far tidier syntax than the native Date.

How you write itResultWhen to use it
{{ $now }}Now, as a Luxon DateTime object with timezone.Timestamps, Gmail subjects, recording when a record was processed
{{ $today }}Today's date with the time at 00:00, decided by the workflow timezone. Behaves much like $now.startOf('day').When you want the date without the time, or to filter for records added today
{{ $now.plus({ days: 3 }) }}The same moment three days later.Setting a due date, or working out “notify if there is no reply within three days”
{{ $now.minus({ hours: 1 }) }}One hour ago.An API query for new data in the last hour
{{ $now.startOf('day') }}Today at 00:00:00.The start time when you want everything from today
{{ $now.toFormat('yyyy-MM-dd') }}The string 2026-08-16.Dropping into a Gmail subject, a file name, or a Sheets column
{{ $now.toFormat('yyyy-MM-dd HH:mm') }}The string 2026-08-16 14:30.A timestamp that has to include hours and minutes
{{ DateTime.fromISO($json.created_at) }}Turns an ISO string returned by an API into a Luxon object.When you need to do arithmetic on, or format, a date an API gave you

Every Luxon calendar unit works — days, hours, months, weeks, years — and you must not drop the plural s. The toFormat pattern is Luxon-specific too: yyyy is a four-digit year, MM a two-digit month, dd a two-digit day, HH the 24-hour clock, mm minutes.

A drifting timezone is the most common date trap. By default n8n uses the system timezone — self-hosted takes the container's timezone, Cloud takes your workspace setting. Open Settings → Timezone in the workflow and choose the IANA timezone for your location, for example Europe/London. After that $now and $today both use that zone. For cross-timezone data, state the destination explicitly with something like .setZone('Europe/London').

Joining strings: three styles, one result

Slack messages, Gmail subjects and Sheets columns are all strings. To drop an upstream field into a sentence, n8n gives you three styles. They all achieve the same thing, so pick whichever reads best to you.

Mixed inHi {{ $json.name }} evaluates to Hi Elmo. The one to prefer — as long as what sits outside the braces is static text, mixing directly is the shortest.
Plus sign{{ 'Hi ' + $json.name }} evaluates to Hi Elmo. Use it when you have to build the string inside the braces, for example to wrap the whole thing in toUpperCase().
Template{{ `Hi ${$json.name}, order ${$json.orderId}` }} evaluates to Hi Elmo, order 1042. Cleanest when one expression has to interpolate several variables with punctuation between them.

The string methods are all standard JavaScript, so nothing here is n8n-specific:

MethodWhat it doesExample
.toUpperCase()Upper-cases everything.{{ $json.code.toUpperCase() }} gives ABC123
.toLowerCase()Lower-cases everything.{{ $json.email.toLowerCase() }}
.trim()Strips leading and trailing whitespace.Use it to clean up the padded name you got out of a Google Form
.slice(0, 100)Takes the first 100 characters.{{ $json.body.slice(0, 100) }} keeps a Slack preview from blowing up
.replace('old', 'new')Replaces text.{{ $json.phone.replace(/-/g, '') }} removes every hyphen
.split(',')Splits into an array.Turns a,b,c into ['a','b','c']
Optional chaining saves you an error. Not sure whether a field on the current item exists? Write {{ $json.name?.toUpperCase() }}. Adding the ? means that when name does not exist you get undefined back instead of a thrown error — far tidier than an if check, which expressions cannot run anyway.

Cases you can copy

These are the ones that come up over and over. Open the workflow, switch the field to Expression mode, and copy them as they are written.

ScenarioExpressionWhat it means
Slack new-order notification {{ `New order #${$json.orderId} | Customer ${$json.customer} | Amount $${$json.amount}` }} Built in one template literal, with the separator characters typed straight in
New Sheets row, name column {{ $('Webhook').item.json.name }} Explicitly reaches back for name on the Webhook node, so it does not break even if an Edit Fields (Set) node is dropped in between
Dynamic date in a Gmail subject {{ 'Daily sales report - ' + $now.toFormat('yyyy-MM-dd') }} Fixed text plus Luxon formatting; at run time it becomes Daily sales report - 2026-08-16
IF condition: amount over 1000 Left value {{ $json.amount }}, operator >, right value 1000 The IF node does not take a raw boolean. The operator is picked in its UI and only the left and right boxes are expression fields. To combine conditions use several conditions plus AND/OR, or put an Edit Fields (Set) node in front to work the boolean out first
Dynamic HTTP Request URL https://api.example.com/orders/{{ $json.orderId }} URLs support mixed expressions too, so the order id goes straight into the path
Turn an array into a string {{ $json.tags.join(', ') }} Some Airtable fields only accept strings, so join turns the array into a comma-separated list
File name with a timestamp {{ 'report_' + $now.toFormat('yyyyMMdd_HHmm') + '.pdf' }} Gives report_20260816_1430.pdf, so nothing is overwritten by a same-named file
Use a value, or fall back {{ $json.nickname ? $json.nickname : $json.name }} Nickname first, falling back to the real name when there is none. There is a shorter form: {{ $json.nickname || $json.name }}
Reading a red result

Four kinds of red text, and what each one means

Red text in an expression field, or a red border around it, is nothing to panic about. n8n tells you the reason right below the expression panel, and once you can read that message you can fix it in seconds. Four kinds cover almost everything.

flowchart LR
  A["The Result line is red,
or not the value you wanted"] --> B{"What does
it say?"} B -->|"undefined"| C["The field name is wrong.
Check the Schema view, or drag it in"] B -->|"Cannot read property"| D["The parent is empty. Use optional
chaining, or fix the upstream node"] B -->|"Referenced node does not exist"| E["Copy the node name off the
canvas, spaces and case included"] B -->|"a schema, no real values"| F["Click Execute step upstream
so it produces real output"]
Four messages, four fixesOne question in the middle, and the four answers fan out into a single column down the right, one box per message. Each is a different layer: your typing, the upstream data, the canvas, and whether anything has run yet. Read the message first — it names which layer to go and look at, and that is faster than changing things until something works.

Result shows undefined

The most common one. Usually a misspelled field name — the real field is name and you typed $json.namee with an extra e. Switch the INPUT panel to the Schema view and confirm how the field is really written: case, spaces, and whether it is nested like data.name. Or just drag it in, because dragging never misspells.

Result shows [Cannot read property 'X' of undefined]

The parent of the thing you are reaching for is empty. Take $json.customer.email: the customer field does not exist, so n8n cannot read email off it. Two fixes — sidestep it with optional chaining, $json.customer?.email, or fix the upstream node first and confirm it really returns customer.

Result shows [Referenced node doesn't exist]

You wrote $('Google Sheets') or $node['Google Sheets'], but there is no node by that name on the canvas. Usually the node was renamed, or the case or the spacing does not match. Go back to the canvas, see what the node is actually called, and copy that name across rather than typing it.

Result shows a schema instead of values

The preview shows the field structure but no real values. That means the upstream node has not run yet, so n8n does not know what it will fetch. Click Execute step once on the upstream node and Result will have real values when you come back.

The Result line is your best debugging tool. While you edit an expression, keep your eyes on it. Check after every few characters whether it has become the value you want. That way you catch a mistake immediately, instead of waiting for a whole workflow run to finish before learning that it blew up.

Pitfalls that are not error messages

Some failures are quieter than a red border, and these are the ones that waste an afternoon.

SymptomSlack sends the literal text {{ $json.name }}. You forgot to flip the field's fx toggle. In Fixed mode the braces are ordinary text and are never evaluated. Open the field, switch it to Expression mode, and save again.
SymptomResult shows undefined and the spelling looks right. Three possibilities: the field name is misspelled after all (check it against Schema view); the upstream node simply does not return that field (look at the actual JSON in its output panel); or the case is wrong, because Name and name are two different things.
SymptomThe preview shows an old value that does not match the actual run. The preview uses the upstream node's last run. If you changed an upstream parameter and have not re-run it, you are looking at stale output. Execute step upstream, then come back.
SymptomAn array asks for .length and gets undefined. That field is not an array. Switch to Schema view and confirm its type — if the API returned something that looks like an array but is really a string, such as "[1,2,3]", you have to turn it into a real array with JSON.parse($json.list) before .length works.

The full variable list, for when you meet one in the wild

Seeing $json, $node and $input mixed in somebody else's workflow is confusing, but the logic is simple. They differ on whose data you are taking, and on whether you take one item or all of them.

VariableWhat it refers toWhen to use it
$jsonThe current iteration's item, specifically its json part.90% of cases. The node runs N times in a row and each time $json points at a different item
$binaryThe binary part of the current item. Only there when it has an attachment.When you handle files: $binary.attachment_0.fileName
$input.itemThe whole current item object, both json and binary.In a Code node, or when you need both parts at once
$input.all()The array of all items this node received. Each element carries .json and .binary.Aggregating over the whole batch — $input.all().length for a count, or $input.all().map(i => i.json.amount).reduce(...) to total one field across every item. Available in expressions and in the Code node
$input.first() / $input.last()The first or last item this node received.Taking the head or tail of a batch for a summary, such as $input.first().json.startedAt
$('NodeName')The output of any upstream node, not only the one directly connected. The newer form.Taking the original data from two or three stops back
$node['NodeName']The same thing, in the older form.You will meet it in older workflows and templates. It does the same job
$now / $todayThe current time, or today at 00:00. A Luxon DateTime in the workflow timezone.Everything to do with dates and times. DateTime.now() gives an object of the same type
$itemIndexThis iteration's index in the node's input array, starting at 0.An opening line for the first record, a separator every 10, or putting “record N” into a message with {{ $itemIndex + 1 }}
$workflowData about the current workflow: .id, .name, .active.Putting the workflow name into a failure notification so it is easy to trace
$executionData about this particular run: .id, .mode, .resumeUrl.Adding the execution id to an error message makes it easy to look up later. The Wait node also resumes through .resumeUrl
$vars / $envWorkflow and environment variables. Every Cloud edition; on self-hosted, $vars needs Enterprise and $env needs N8N_BLOCK_ENV_ACCESS_IN_NODE=false.Keeping API endpoints and thresholds in one place instead of rewriting every workflow

Three common accessors go with the node forms: $('NodeName').first() and $('NodeName').last() take one item from the head or the tail, and $('NodeName').all() takes the whole array. Alongside them, $('NodeName').item takes the item matching the current iteration, aligned through pairedItem as Part 7 explained.

Questions people ask once and then stop asking

Is there a full syntax spec for expressions?
Yes. At run time an expression is a subset of JavaScript, plus n8n's built-in variables, plus the Luxon date library. Almost the entire single-expression part of JS works: arithmetic, string methods, array methods, the ternary operator, optional chaining, template literals. But you cannot declare function, const or let, cannot await, cannot require an external package, and cannot write if or for statements. Dates go through Luxon — use $now, $today, or the global DateTime to build an object, for example DateTime.now() or DateTime.fromISO(...). The official docs are at docs.n8n.io/code/expressions/ and docs.n8n.io/code/builtin/.
Can I write a for loop or an if inside an expression?
A simple ternary, a ? b : c, is fine, and so is a short || check such as $json.x || 'default'. But when you genuinely need a for loop, nested ifs or a complex data transformation, do not force it into an expression — it becomes very hard to read and to debug. Move to the Code node, which Part 15 covers, where you write full JavaScript or Python and get syntax highlighting with it. Expressions suit a dynamic value you can state in one line; the Code node suits work that has logic to run.
Are expressions slow to run?
Usually not. An expression is plain string, date and array work that n8n computes in memory, on the order of microseconds per item. What is genuinely slow is the external API call — even when your expression does almost nothing, a Slack or Google Sheets node waiting for an API reply is slow. So the answer to “the workflow is slow” is usually the integration nodes from Part 8 waiting on an outside service, not the expression.
How do I look up which built-in functions are available?
Once the expression editor is open, a small categorized list slides out on the right. Some versions call it helper and some call it variables. It lists every available variable that starts with $ — $json, $now, $workflow and the rest — plus the common Luxon methods, and clicking a name gives you its description and a short example. If you cannot find it there, the full list is in the official docs at docs.n8n.io/code/builtin/, and Luxon's own date methods are documented at moment.github.io/luxon/.
How is $json.field different from $('Previous Node').item.json.field?
For the node directly upstream, the one wired to yours, what they give you is almost identical — both are the item for the current iteration. The difference is explicitness and stability. $json says “whatever the upstream is, this is the data I just received”. $('NodeName') says “go and take it from that node”. When the workflow has branches, or a Merge or Edit Fields (Set) node dropped in the middle, $('NodeName') lets you skip the intermediate stops and take the original data, so a field rewritten along the way cannot trip you up. Start with $json as a beginner; once workflows get complex you will reach for $('NodeName') naturally.
Can I save an expression as a constant and reuse it?
Expressions have no concept of declaring a variable — you cannot write const x = .... There are two ways to reuse the same calculation. Use an Edit Fields (Set) node to work it out first and store it in a field, then have every downstream node take it with $('Set').item.json.myVar. Or use workflow-level Variables, available on Cloud and on self-hosted Enterprise, defined in Settings and read anywhere in the workflow with $vars.myVar. In practice the Set node is the most direct route for a beginner.
Someone's workflow uses $node['Foo'].json.bar — should I change it?
You can: the two forms are exactly equivalent. n8n keeps the old $node[] form for backward compatibility while promoting the newer $('NodeName'). Use the new one in new projects — it is shorter and it is what the official docs lead with — but there is no need to rewrite old workflows. Mixing them will not break anything, it just looks inconsistent. Part 4, where you learn to edit somebody else's workflow, is where you will run into the old form most often. Being able to read it is enough.
Next

Where to go from here

keep going

Your nodes can connect, and they can read data. Now teach them to choose.

Part 11 starts flow control: the IF node and the Switch node, which take the expressions you just learned and turn them into branches — one path when the amount is over 1000, another when it is not — and then Merge, for putting the branches back together again.

Open the full guide

Part 10 of the Woow n8n Onboarding Guide series on the Apporo blog.

Adapted from the Woow n8n Onboarding Guide, produced by WoowTech and released under CC BY 4.0. This adaptation is published by Apporo under the same licence.

Light · Air · Water · Control · apporo

in n8n
The node that reaches every API n8n has never heard of