> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Port Security on Access Ports with Ansible on Cisco IOS XE
- URL: https://www.pinglabz.com/ansible-port-security-ios/
- Published: 2026-11-01T03:16:55.000Z
- Updated: 2026-11-01T04:41:42.000Z
- Description: Every access port gets a two MAC limit, restrict mode and aging from one data model. Then a test host tries four MAC addresses, three get dropped, and the check playbook catches the violations.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

An access port with no limits will learn any MAC address that shows up on it. Plug in a small unmanaged switch under a desk, or run a tool that sends from random source addresses, and the access switch learns all of them. Port security puts a ceiling on that. This post adds a port security baseline to every access port in the campus data model from [part 10](https://www.pinglabz.com/ansible-ios-vlans-trunks-svis-resource-modules/), tests it with a host that changes its MAC address, and adds a check playbook that fails when a port has seen a violation.

It was built in my home lab on the same three IOS XE L2 lab switches as the last post: hq-dist1 and two access switches, hq-acc1 and hq-acc2\. A Linux test host, desk-a, sits on hq-acc1 Ethernet0/2 in VLAN 10\. The previous post was [part 16, STP root, portfast and LACP](https://www.pinglabz.com/ansible-stp-etherchannel-lacp-ios/).

Versions: ansible-core 2.21, cisco.ios 11.6.

[![Lab diagram: desk-a, a Linux host at 10.66.110.50/24, pings the VLAN 10 gateway 10.66.110.1 on hq-dist1 from four MAC addresses through hq-acc1 Ethernet0/2. Port security there allows a maximum of 2 addresses in restrict mode with 60 minute aging. The secure table holds desk-a's own MAC aac1.ab17.9df4 and 0200.0000.c001, so c001 gets 3 of 3 ping replies while c002, c003 and c004 are dropped. The port stays Secure-up, the violation count reaches 28, and four PSECURE_VIOLATION log lines name c002, c003 and c004 (twice).](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-5.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-5.png?ref=pinglabz.com)

Two secure slots on hq-acc1 Et0/2: desk-a's own MAC and c001 get through, c002 to c004 are dropped and counted.

## The baseline

The settings live in one small vars file:

```yaml
---
# Part 17: port security on the HQ access ports
port_security:
  maximum: 2              # a PC and the phone it plugs into
  violation: restrict     # drop and count, keep the port up
  aging_minutes: 60

```

Restrict drops frames from any MAC address over the limit, logs it and counts it, but leaves the port up. Shutdown, the default, puts the port in err-disabled and the user loses their connection until someone clears it. Restrict is the gentler place to start on a campus that has never had port security, because you get the logs without the helpdesk calls. Aging means a learned address leaves the table 60 minutes after it was learned, so a desk that gets a new laptop recovers on its own.

On a desk port with a phone, check the limit against your phones. Cisco's guidance is that a phone can be learned on both the data and the voice VLAN, and many designs use 3 for that reason. This lab has no phones, so 2 is what I tested.

## The playbook

The access ports come straight from the campus model, so there is no second list to keep in sync:

```yaml
---
- name: Port security on every access port
  hosts: access
  gather_facts: false
  vars_files:
    - ../vars/campus.yml
    - ../vars/port-security.yml
  vars:
    edge_ports: "{{ ports[inventory_hostname] | selectattr('mode', 'eq', 'access') | map(attribute='name') | list }}"
  tasks:
    - name: Limit, violation action and aging, then switch it on
      cisco.ios.ios_config:
        parents: "interface {{ item }}"
        lines:
          - switchport port-security maximum {{ port_security.maximum }}
          - switchport port-security violation {{ port_security.violation }}
          - switchport port-security aging time {{ port_security.aging_minutes }}
          - switchport port-security
      loop: "{{ edge_ports }}"
      register: r_edge

    - name: Commands sent
      ansible.builtin.debug:
        msg: "{{ r_edge.results | map(attribute='updates', default=[]) | flatten }}"

```

`switchport port-security` goes last on purpose. The limit and the violation mode are in place before the feature turns on, so the port never runs for a moment with the default limit of one address and shutdown mode.

There is no resource module for port security in cisco.ios, so this is `ios_config`. That works here because IOS writes these lines back in the running config exactly as they were sent, which keeps the task idempotent.

## First and second run

First run:

```text
TASK [Limit, violation action and aging, then switch it on] ********************
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
changed: [hq-acc1] => (item=Ethernet0/2)
changed: [hq-acc2] => (item=Ethernet0/2)
changed: [hq-acc1] => (item=Ethernet0/3)
changed: [hq-acc2] => (item=Ethernet0/3)

TASK [Commands sent] ***********************************************************
ok: [hq-acc1] => {
    "msg": [
        "interface Ethernet0/2",
        "switchport port-security maximum 2",
        "switchport port-security violation restrict",
        "switchport port-security aging time 60",
        "switchport port-security",
        "interface Ethernet0/3",
        "switchport port-security maximum 2",
        "switchport port-security violation restrict",
        "switchport port-security aging time 60",
        "switchport port-security"
    ]
}

```

hq-acc2 got the same eight lines. The warning is a general one that `ios_config` prints when it cannot be sure the lines match the running config. The second run is the real test:

```text
TASK [Limit, violation action and aging, then switch it on] ********************
ok: [hq-acc1] => (item=Ethernet0/2)
ok: [hq-acc2] => (item=Ethernet0/2)
ok: [hq-acc1] => (item=Ethernet0/3)
ok: [hq-acc2] => (item=Ethernet0/3)

TASK [Commands sent] ***********************************************************
ok: [hq-acc1] => {
    "msg": []
}
ok: [hq-acc2] => {
    "msg": []
}

PLAY RECAP *********************************************************************
hq-acc1                    : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
hq-acc2                    : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

```

Nothing changed, so the lines match what the switch stores. The desk port now looks like this, with the portfast and BPDU guard lines from part 16 still there:

```text
interface Ethernet0/2
 description DESK: 2F-14
 switchport access vlan 10
 switchport mode access
 switchport voice vlan 20
 switchport port-security maximum 2
 switchport port-security violation restrict
 switchport port-security aging time 60
 switchport port-security
 spanning-tree portfast
 spanning-tree bpduguard enable
end

```

## The check playbook

Config being present does not tell you whether a port has been tripping. The check reads `show port-security interface` for each access port and fails if the feature is off, the port is not Secure-up, or the violation count is above zero:

```yaml
---
- name: Check port security on the access ports
  hosts: access
  gather_facts: false
  vars_files:
    - ../vars/campus.yml
  vars:
    edge_ports: "{{ ports[inventory_hostname] | selectattr('mode', 'eq', 'access') | map(attribute='name') | list }}"
  tasks:
    - name: Read port security per access port
      cisco.ios.ios_command:
        commands: "{{ edge_ports | map('regex_replace', '^', 'show port-security interface ') | list }}"
      register: r

    - name: Show the lines that matter
      ansible.builtin.debug:
        msg: >-
          {%- set out = [] -%}
          {%- for p in edge_ports -%}
          {%- set keep = r.stdout[loop.index0].splitlines() | select('search', 'Port Status|Total MAC|Violation Count') | map('regex_replace', '\s+', ' ') | join(' | ') -%}
          {%- set _ = out.append(p ~ ': ' ~ keep) -%}
          {%- endfor -%}
          {{ out }}

    - name: Enabled, secure-up, no violations
      ansible.builtin.assert:
        that:
          - r.stdout[idx] is search('Port Security\s+:\s+Enabled')
          - r.stdout[idx] is search('Secure-up')
          - r.stdout[idx] is search('Security Violation Count\s+:\s+0$', multiline=True)
        fail_msg: "{{ item }} is not clean"
        quiet: true
      loop: "{{ edge_ports }}"
      loop_control:
        index_var: idx

```

Right after the second run, all four ports passed:

```text
TASK [Show the lines that matter] **********************************************
ok: [hq-acc1] => {
    "msg": [
        "Ethernet0/2: Port Status : Secure-up | Total MAC Addresses : 0 | Security Violation Count : 0",
        "Ethernet0/3: Port Status : Secure-up | Total MAC Addresses : 0 | Security Violation Count : 0"
    ]
}
ok: [hq-acc2] => {
    "msg": [
        "Ethernet0/2: Port Status : Secure-up | Total MAC Addresses : 0 | Security Violation Count : 0",
        "Ethernet0/3: Port Status : Secure-up | Total MAC Addresses : 0 | Security Violation Count : 0"
    ]
}

TASK [Enabled, secure-up, no violations] ***************************************
ok: [hq-acc1] => (item=Ethernet0/2)
ok: [hq-acc2] => (item=Ethernet0/2)
ok: [hq-acc1] => (item=Ethernet0/3)
ok: [hq-acc2] => (item=Ethernet0/3)

```

## Four MAC addresses on one port

To trip it, desk-a took the address 10.66.110.50/24 and then cycled through four MAC addresses, pinging the VLAN 10 gateway on hq-dist1 (10.66.110.1) from each one. For every MAC the host ran:

```text
ip link set eth1 down
ip link set eth1 address 02:00:00:00:c0:0N
ip link set eth1 up
ping -c 3 -W 1 10.66.110.1

```

The ping results:

```text
02:00:00:00:c0:01: 3 packets transmitted, 3 packets received, 0% packet loss
02:00:00:00:c0:02: 3 packets transmitted, 0 packets received, 100% packet loss
02:00:00:00:c0:03: 3 packets transmitted, 0 packets received, 100% packet loss
02:00:00:00:c0:04: 3 packets transmitted, 0 packets received, 100% packet loss

```

With a limit of 2 you might expect two of the four to work. Only one did, and the secure address table shows why:

```text
Vlan    Mac Address       Type                          Ports   Remaining Age
                                                                   (mins)    
----    -----------       ----                          -----   -------------
  10    0200.0000.c001    SecureDynamic                 Et0/2       59
  10    aac1.ab17.9df4    SecureDynamic                 Et0/2       60

```

The second slot went to desk-a's original MAC address, which the switch learned from traffic before the test started. That is how it works on a real desk too: the limit counts every address the port has seen within the aging time, not just the ones you were thinking about.

The port itself:

```text
Port Security              : Enabled
Port Status                : Secure-up
Violation Mode             : Restrict
Aging Time                 : 60 mins
Aging Type                 : Absolute
SecureStatic Address Aging : Disabled
Maximum MAC Addresses      : 2
Total MAC Addresses        : 2
Configured MAC Addresses   : 0
Sticky MAC Addresses       : 0
Last Source Address:Vlan   : 0200.0000.c004:10
Security Violation Count   : 28

```

Still Secure-up, which is the point of restrict. The violation count is 28 rather than 3 because restrict counts every dropped frame, not every new address. Pings, ARP requests and anything else the host sent from the three extra addresses all count.

And the log, from `show logging | include PSECURE`:

```text
*Nov  1 03:13:38.219: %PORT_SECURITY-2-PSECURE_VIOLATION: Security violation occurred, caused by MAC address 0200.0000.c002 on port Ethernet0/2.
*Nov  1 03:13:43.775: %PORT_SECURITY-2-PSECURE_VIOLATION: Security violation occurred, caused by MAC address 0200.0000.c003 on port Ethernet0/2.
*Nov  1 03:13:49.324: %PORT_SECURITY-2-PSECURE_VIOLATION: Security violation occurred, caused by MAC address 0200.0000.c004 on port Ethernet0/2.
*Nov  1 03:13:54.684: %PORT_SECURITY-2-PSECURE_VIOLATION: Security violation occurred, caused by MAC address 0200.0000.c004 on port Ethernet0/2.

```

The switch clock is in UTC. That is four lines for three addresses, with c004 logged twice, so it is not one line per address, but it is nowhere near one per dropped frame either, which keeps the log readable. That is a good line to send to syslog and alert on.

The check playbook, run again, caught it:

```text
TASK [Show the lines that matter] **********************************************
ok: [hq-acc1] => {
    "msg": [
        "Ethernet0/2: Port Status : Secure-up | Total MAC Addresses : 2 | Security Violation Count : 29",
        "Ethernet0/3: Port Status : Secure-up | Total MAC Addresses : 0 | Security Violation Count : 0"
    ]
}

TASK [Enabled, secure-up, no violations] ***************************************
[ERROR]: Task failed: Action failed: Ethernet0/2 is not clean
failed: [hq-acc1] (item=Ethernet0/2) => {"ansible_index_var": "idx", "ansible_loop_var": "item", "assertion": "r.stdout[idx] is search('Security Violation Count\\s+:\\s+0$', multiline=True)", "changed": false, "evaluated_to": false, "idx": 0, "item": "Ethernet0/2", "msg": "Ethernet0/2 is not clean"}
ok: [hq-acc2] => (item=Ethernet0/2)
ok: [hq-acc2] => (item=Ethernet0/3)
ok: [hq-acc1] => (item=Ethernet0/3)

```

The count went up to 29 between the two reads because the host was still sending from an address the port would not accept. The failed assertion names the exact port, and the other three still pass. In restrict mode nobody's port goes down, so this check is the thing that tells you a violation happened at all.

To reset, I put desk-a back on its original MAC address, removed `switchport port-security` from Ethernet0/2 and ran the playbook again, which turned it back on with an empty table and a count of zero. The check passed on all four ports after that.

## What about DHCP snooping

I planned to cover DHCP snooping in this post as well, and I did test it. It did not enforce on this lab. These switches run the L2 lab image, and with snooping enabled on VLANs 10, 20 and 30 and the uplinks trusted, `show ip dhcp snooping` said it was operational on all three VLANs. But `show ip dhcp snooping statistics` stayed at 0 packets processed, the binding table stayed empty, and a rogue DHCP server I connected to an untrusted access port kept receiving DISCOVER messages from the test host as if snooping were not there. I removed the config instead of publishing results I could not stand behind.

On real Catalyst hardware, Cisco documents the behavior you should see: server messages (OFFER and ACK) arriving on an untrusted port are dropped, the switch builds a binding table of MAC, IP, VLAN and port from the leases it watches, and the statistics counters go up. If you build this on physical switches, check those three things: the drop counters go up when a rogue server answers, `show ip dhcp snooping binding` fills in as clients get leases, and the uplink toward the real DHCP server is trusted. If the packet counter stays at zero, as it did here, snooping is not looking at the traffic, whatever the status line says.

## Where to take it

- Once the logs have been quiet for a few weeks, change `violation` to `shutdown` in the vars file and run the playbook. It is one line in one place.
- Add `errdisable recovery cause psecure-violation` with an interval if you move to shutdown and do not want every violation to need a ticket.
- Send `PSECURE_VIOLATION` to syslog and alert on it, and run the check on a schedule.

[Part 18](https://www.pinglabz.com/ansible-ios-acls-resource-module/) covers IOS XE ACLs with the ios\_acls resource module. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).